owlplanner 2025.4.28__py3-none-any.whl → 2025.5.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/abcapi.py +17 -23
- owlplanner/plan.py +321 -298
- owlplanner/tax2025.py +2 -31
- owlplanner/version.py +1 -1
- {owlplanner-2025.4.28.dist-info → owlplanner-2025.5.1.dist-info}/METADATA +23 -27
- {owlplanner-2025.4.28.dist-info → owlplanner-2025.5.1.dist-info}/RECORD +8 -8
- {owlplanner-2025.4.28.dist-info → owlplanner-2025.5.1.dist-info}/WHEEL +0 -0
- {owlplanner-2025.4.28.dist-info → owlplanner-2025.5.1.dist-info}/licenses/LICENSE +0 -0
owlplanner/abcapi.py
CHANGED
|
@@ -47,12 +47,10 @@ class Row(object):
|
|
|
47
47
|
self.ind.append(ind)
|
|
48
48
|
self.val.append(val)
|
|
49
49
|
|
|
50
|
-
def addElemDic(self, rowDic=
|
|
50
|
+
def addElemDic(self, rowDic={}):
|
|
51
51
|
"""
|
|
52
52
|
Add elements at indices provided by a dictionary.
|
|
53
53
|
"""
|
|
54
|
-
if rowDic is None:
|
|
55
|
-
rowDic = {}
|
|
56
54
|
for key in rowDic:
|
|
57
55
|
self.addElem(key, rowDic[key])
|
|
58
56
|
return self
|
|
@@ -75,13 +73,11 @@ class ConstraintMatrix(object):
|
|
|
75
73
|
self.ub = []
|
|
76
74
|
self.key = []
|
|
77
75
|
|
|
78
|
-
def newRow(self, rowDic=
|
|
76
|
+
def newRow(self, rowDic={}):
|
|
79
77
|
"""
|
|
80
78
|
Create a new row and populate its elements using the dictionary provided.
|
|
81
79
|
Return the row created.
|
|
82
80
|
"""
|
|
83
|
-
if rowDic is None:
|
|
84
|
-
rowDic = {}
|
|
85
81
|
row = Row(self.nvars)
|
|
86
82
|
row.addElemDic(rowDic)
|
|
87
83
|
return row
|
|
@@ -97,10 +93,12 @@ class ConstraintMatrix(object):
|
|
|
97
93
|
self.ub.append(ub)
|
|
98
94
|
if lb == ub:
|
|
99
95
|
self.key.append("fx")
|
|
100
|
-
elif lb == -np.inf:
|
|
101
|
-
self.key.append("
|
|
96
|
+
elif ub == np.inf and lb == -np.inf:
|
|
97
|
+
self.key.append("fr")
|
|
102
98
|
elif ub == np.inf:
|
|
103
99
|
self.key.append("lo")
|
|
100
|
+
elif lb == -np.inf:
|
|
101
|
+
self.key.append("up")
|
|
104
102
|
else:
|
|
105
103
|
self.key.append("ra")
|
|
106
104
|
self.ncons += 1
|
|
@@ -147,13 +145,16 @@ class Bounds(object):
|
|
|
147
145
|
Solver-neutral API for bounds on variables.
|
|
148
146
|
"""
|
|
149
147
|
|
|
150
|
-
def __init__(self, nvars):
|
|
148
|
+
def __init__(self, nvars, nbins):
|
|
151
149
|
self.nvars = nvars
|
|
150
|
+
self.nbins = nbins
|
|
152
151
|
self.ind = []
|
|
153
152
|
self.lb = []
|
|
154
153
|
self.ub = []
|
|
155
154
|
self.key = []
|
|
156
155
|
self.integrality = []
|
|
156
|
+
for ii in range(nvars-nbins, nvars):
|
|
157
|
+
self.setBinary(ii)
|
|
157
158
|
|
|
158
159
|
def setBinary(self, ii):
|
|
159
160
|
assert 0 <= ii and ii < self.nvars, f"Index {ii} out of range."
|
|
@@ -163,27 +164,20 @@ class Bounds(object):
|
|
|
163
164
|
self.key.append("ra")
|
|
164
165
|
self.integrality.append(ii)
|
|
165
166
|
|
|
166
|
-
def set0_Ub(self, ii, ub):
|
|
167
|
-
assert 0 <= ii and ii < self.nvars, f"Index {ii} out of range."
|
|
168
|
-
self.ind.append(ii)
|
|
169
|
-
self.lb.append(0)
|
|
170
|
-
self.ub.append(ub)
|
|
171
|
-
self.key.append("ra")
|
|
172
|
-
|
|
173
|
-
def setLb_Inf(self, ii, lb):
|
|
174
|
-
assert 0 <= ii and ii < self.nvars, f"Index {ii} out of range."
|
|
175
|
-
self.ind.append(ii)
|
|
176
|
-
self.lb.append(lb)
|
|
177
|
-
self.ub.append(np.inf)
|
|
178
|
-
self.key.append("lo")
|
|
179
|
-
|
|
180
167
|
def setRange(self, ii, lb, ub):
|
|
181
168
|
assert 0 <= ii and ii < self.nvars, f"Index {ii} out of range."
|
|
169
|
+
assert lb <= ub, f"Lower bound {lb} > upper bound {ub}."
|
|
182
170
|
self.ind.append(ii)
|
|
183
171
|
self.lb.append(lb)
|
|
184
172
|
self.ub.append(ub)
|
|
185
173
|
if lb == ub:
|
|
186
174
|
self.key.append("fx")
|
|
175
|
+
elif ub == np.inf and lb == -np.inf:
|
|
176
|
+
self.key.append("fr")
|
|
177
|
+
elif ub == np.inf:
|
|
178
|
+
self.key.append("lo")
|
|
179
|
+
elif lb == -np.inf:
|
|
180
|
+
self.key.append("up")
|
|
187
181
|
else:
|
|
188
182
|
self.key.append("ra")
|
|
189
183
|
|
owlplanner/plan.py
CHANGED
|
@@ -230,12 +230,11 @@ class Plan(object):
|
|
|
230
230
|
self._name = name
|
|
231
231
|
self.setLogstreams(verbose, logstreams)
|
|
232
232
|
|
|
233
|
-
# 7 tax brackets,
|
|
233
|
+
# 7 tax brackets, 3 types of accounts, 4 classes of assets.
|
|
234
234
|
self.N_t = 7
|
|
235
|
-
self.N_q = 6
|
|
236
235
|
self.N_j = 3
|
|
237
236
|
self.N_k = 4
|
|
238
|
-
# 2 binary variables
|
|
237
|
+
# 2 binary variables.
|
|
239
238
|
self.N_z = 2
|
|
240
239
|
|
|
241
240
|
# Default interpolation parameters for allocation ratios.
|
|
@@ -305,8 +304,8 @@ class Plan(object):
|
|
|
305
304
|
self.myRothX_in = np.zeros((self.N_i, self.N_n))
|
|
306
305
|
self.kappa_ijn = np.zeros((self.N_i, self.N_j, self.N_n))
|
|
307
306
|
|
|
308
|
-
# Previous
|
|
309
|
-
self.
|
|
307
|
+
# Previous 3 years for Medicare.
|
|
308
|
+
self.prevMAGI = np.zeros((3))
|
|
310
309
|
|
|
311
310
|
# Default slack on profile.
|
|
312
311
|
self.lambdha = 0
|
|
@@ -324,7 +323,7 @@ class Plan(object):
|
|
|
324
323
|
# If none was given, default is to begin plan on today's date.
|
|
325
324
|
self._setStartingDate(startDate)
|
|
326
325
|
|
|
327
|
-
|
|
326
|
+
self._buildOffsetMap()
|
|
328
327
|
|
|
329
328
|
# Initialize guardrails to ensure proper configuration.
|
|
330
329
|
self._adjustedParameters = False
|
|
@@ -1022,30 +1021,30 @@ class Plan(object):
|
|
|
1022
1021
|
|
|
1023
1022
|
return None
|
|
1024
1023
|
|
|
1025
|
-
def _buildOffsetMap(self
|
|
1024
|
+
def _buildOffsetMap(self):
|
|
1026
1025
|
"""
|
|
1027
1026
|
Utility function to map variables to a block vector.
|
|
1028
1027
|
Refer to companion document for explanations.
|
|
1029
1028
|
"""
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
# Stack all variables in a single block vector with offsets saved in a dictionary.
|
|
1029
|
+
# Stack all variables in a single block vector with all binary variables at the end.
|
|
1033
1030
|
C = {}
|
|
1034
1031
|
C["b"] = 0
|
|
1035
1032
|
C["d"] = _qC(C["b"], self.N_i, self.N_j, self.N_n + 1)
|
|
1036
1033
|
C["e"] = _qC(C["d"], self.N_i, self.N_n)
|
|
1037
1034
|
C["F"] = _qC(C["e"], self.N_n)
|
|
1038
1035
|
C["g"] = _qC(C["F"], self.N_t, self.N_n)
|
|
1039
|
-
C["
|
|
1040
|
-
C["s"] = _qC(C["m"], self.N_n) if medi else C["m"]
|
|
1036
|
+
C["s"] = _qC(C["g"], self.N_n)
|
|
1041
1037
|
C["w"] = _qC(C["s"], self.N_n)
|
|
1042
1038
|
C["x"] = _qC(C["w"], self.N_i, self.N_j, self.N_n)
|
|
1043
|
-
C["
|
|
1044
|
-
|
|
1045
|
-
self.
|
|
1039
|
+
C["z"] = _qC(C["x"], self.N_i, self.N_n)
|
|
1040
|
+
self.nvars = _qC(C["z"], self.N_i, self.N_n, self.N_z)
|
|
1041
|
+
self.nbins = self.nvars - C["z"]
|
|
1042
|
+
# # self.nvars = _qC(C["x"], self.N_i, self.N_n)
|
|
1043
|
+
# # self.nbins = 0
|
|
1046
1044
|
|
|
1047
1045
|
self.C = C
|
|
1048
|
-
self.mylog.vprint(
|
|
1046
|
+
self.mylog.vprint(
|
|
1047
|
+
f"Problem has {len(C)} distinct series, {self.nvars} decision variables (including {self.nbins} binary).")
|
|
1049
1048
|
|
|
1050
1049
|
return None
|
|
1051
1050
|
|
|
@@ -1062,7 +1061,6 @@ class Plan(object):
|
|
|
1062
1061
|
Ni = self.N_i
|
|
1063
1062
|
Nj = self.N_j
|
|
1064
1063
|
Nk = self.N_k
|
|
1065
|
-
Nq = self.N_q
|
|
1066
1064
|
Nn = self.N_n
|
|
1067
1065
|
Nt = self.N_t
|
|
1068
1066
|
Nz = self.N_z
|
|
@@ -1070,19 +1068,15 @@ class Plan(object):
|
|
|
1070
1068
|
i_s = self.i_s
|
|
1071
1069
|
n_d = self.n_d
|
|
1072
1070
|
|
|
1073
|
-
self._buildOffsetMap(options)
|
|
1074
|
-
|
|
1075
1071
|
Cb = self.C["b"]
|
|
1076
1072
|
Cd = self.C["d"]
|
|
1077
1073
|
Ce = self.C["e"]
|
|
1078
1074
|
CF = self.C["F"]
|
|
1079
1075
|
Cg = self.C["g"]
|
|
1080
|
-
Cm = self.C["m"]
|
|
1081
1076
|
Cs = self.C["s"]
|
|
1082
1077
|
Cw = self.C["w"]
|
|
1083
1078
|
Cx = self.C["x"]
|
|
1084
|
-
|
|
1085
|
-
Czm = self.C["zm"]
|
|
1079
|
+
Cz = self.C["z"]
|
|
1086
1080
|
|
|
1087
1081
|
spLo = 1 - self.lambdha
|
|
1088
1082
|
spHi = 1 + self.lambdha
|
|
@@ -1105,79 +1099,37 @@ class Plan(object):
|
|
|
1105
1099
|
###################################################################
|
|
1106
1100
|
# Inequality constraint matrix with upper and lower bound vectors.
|
|
1107
1101
|
A = abc.ConstraintMatrix(self.nvars)
|
|
1108
|
-
B = abc.Bounds(self.nvars)
|
|
1109
|
-
|
|
1110
|
-
# Start with constraints that depend on objective function.
|
|
1111
|
-
if objective == "maxSpending":
|
|
1112
|
-
# Impose optional constraint on final bequest requested in today's $.
|
|
1113
|
-
# If not specified, defaults to $1 (nominal $).
|
|
1114
|
-
bequest = options.get("bequest", 1)
|
|
1115
|
-
assert isinstance(bequest, (int, float)), "Desired bequest is not a number."
|
|
1116
|
-
bequest *= units * self.gamma_n[-1]
|
|
1102
|
+
B = abc.Bounds(self.nvars, self.nbins)
|
|
1117
1103
|
|
|
1118
|
-
|
|
1119
|
-
for i in range(Ni):
|
|
1120
|
-
row.addElem(_q3(Cb, i, 0, Nn, Ni, Nj, Nn + 1), 1)
|
|
1121
|
-
row.addElem(_q3(Cb, i, 1, Nn, Ni, Nj, Nn + 1), 1 - self.nu)
|
|
1122
|
-
# Nudge could be added (e.g. 1.02) to artificially favor tax-exempt account
|
|
1123
|
-
# as heirs's benefits of 10y tax-free is not weighted in?
|
|
1124
|
-
row.addElem(_q3(Cb, i, 2, Nn, Ni, Nj, Nn + 1), 1)
|
|
1125
|
-
A.addRow(row, bequest, bequest)
|
|
1126
|
-
# self.mylog.vprint('Adding bequest constraint of:', u.d(bequest))
|
|
1127
|
-
elif objective == "maxBequest":
|
|
1128
|
-
spending = options["netSpending"]
|
|
1129
|
-
assert isinstance(spending, (int, float)), "Desired spending provided is not a number."
|
|
1130
|
-
# Account for time elapsed in the current year.
|
|
1131
|
-
spending *= units * self.yearFracLeft
|
|
1132
|
-
# self.mylog.vprint('Maximizing bequest with desired net spending of:', u.d(spending))
|
|
1133
|
-
# To allow slack in first year, Cg can be made Nn+1 and store basis in g[Nn].
|
|
1134
|
-
A.addNewRow({_q1(Cg, 0, Nn): 1}, spending, spending)
|
|
1135
|
-
|
|
1136
|
-
# RMDs inequalities.
|
|
1137
|
-
# Impose even if there is no initial balance in tax-deferred account
|
|
1138
|
-
# as contributions can happen later...
|
|
1104
|
+
# RMDs inequalities, only if there is an initial balance in tax-deferred account.
|
|
1139
1105
|
for i in range(Ni):
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1106
|
+
if self.beta_ij[i, 1] > 0:
|
|
1107
|
+
for n in range(self.horizons[i]):
|
|
1108
|
+
rowDic = {
|
|
1109
|
+
_q3(Cw, i, 1, n, Ni, Nj, Nn): 1,
|
|
1110
|
+
_q3(Cb, i, 1, n, Ni, Nj, Nn + 1): -self.rho_in[i, n],
|
|
1111
|
+
}
|
|
1112
|
+
A.addNewRow(rowDic, zero, inf)
|
|
1146
1113
|
|
|
1147
|
-
# Income tax bracket range inequalities
|
|
1114
|
+
# Income tax bracket range inequalities.
|
|
1148
1115
|
for t in range(Nt):
|
|
1149
1116
|
for n in range(Nn):
|
|
1150
|
-
B.
|
|
1117
|
+
B.setRange(_q2(CF, t, n, Nt, Nn), zero, self.DeltaBar_tn[t, n])
|
|
1151
1118
|
|
|
1152
1119
|
# Standard exemption range inequalities.
|
|
1153
1120
|
for n in range(Nn):
|
|
1154
|
-
B.
|
|
1121
|
+
B.setRange(_q1(Ce, n, Nn), zero, self.sigmaBar_n[n])
|
|
1155
1122
|
|
|
1156
|
-
#
|
|
1123
|
+
# Start with no activities after passing.
|
|
1157
1124
|
for i in range(Ni):
|
|
1158
|
-
for
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
_q2(Cx, i, n, Ni, Nn): -u.krond(j, 1),
|
|
1162
|
-
_q3(Cb, i, j, n, Ni, Nj, Nn + 1): 1}
|
|
1163
|
-
A.addNewRow(rowDic, zero, inf)
|
|
1164
|
-
|
|
1165
|
-
# No posthumous account activities.
|
|
1166
|
-
if Ni == 2:
|
|
1167
|
-
# No conversion during last year.
|
|
1168
|
-
# B.set0_Ub(_q2(Cx, i_d, nd-1, Ni, Nn), zero)
|
|
1169
|
-
# B.set0_Ub(_q2(Cx, i_s, Nn-1, Ni, Nn), zero)
|
|
1170
|
-
|
|
1171
|
-
# No withdrawals or deposits for any i_d-owned accounts after year of passing.
|
|
1172
|
-
# Implicit n_d < Nn imposed by for loop.
|
|
1173
|
-
for n in range(n_d, Nn):
|
|
1174
|
-
B.set0_Ub(_q2(Cd, i_d, n, Ni, Nn), zero)
|
|
1175
|
-
B.set0_Ub(_q2(Cx, i_d, n, Ni, Nn), zero)
|
|
1125
|
+
for n in range(self.horizons[i], Nn):
|
|
1126
|
+
B.setRange(_q2(Cd, i, n, Ni, Nn), zero, zero)
|
|
1127
|
+
B.setRange(_q2(Cx, i, n, Ni, Nn), zero, zero)
|
|
1176
1128
|
for j in range(Nj):
|
|
1177
|
-
B.
|
|
1129
|
+
B.setRange(_q3(Cw, i, j, n, Ni, Nj, Nn), zero, zero)
|
|
1178
1130
|
|
|
1179
|
-
# Roth conversions
|
|
1180
|
-
#
|
|
1131
|
+
# Roth conversions equalities/inequalities.
|
|
1132
|
+
# This condition supercedes everything else.
|
|
1181
1133
|
if "maxRothConversion" in options and options["maxRothConversion"] == "file":
|
|
1182
1134
|
# self.mylog.vprint(f"Fixing Roth conversions to those from file {self.timeListsFileName}.")
|
|
1183
1135
|
for i in range(Ni):
|
|
@@ -1196,8 +1148,9 @@ class Plan(object):
|
|
|
1196
1148
|
# self.mylog.vprint('Limiting Roth conversions to:', u.d(rhsopt))
|
|
1197
1149
|
for i in range(Ni):
|
|
1198
1150
|
for n in range(self.horizons[i]):
|
|
1199
|
-
#
|
|
1200
|
-
|
|
1151
|
+
# MOSEK chokes if completely zero. Add a 1 cent slack.
|
|
1152
|
+
# Should we adjust Roth conversion cap with inflation?
|
|
1153
|
+
B.setRange(_q2(Cx, i, n, Ni, Nn), zero, rhsopt + 0.01)
|
|
1201
1154
|
|
|
1202
1155
|
# Process startRothConversions option.
|
|
1203
1156
|
if "startRothConversions" in options:
|
|
@@ -1209,7 +1162,7 @@ class Plan(object):
|
|
|
1209
1162
|
for i in range(Ni):
|
|
1210
1163
|
nstart = min(yearn, self.horizons[i])
|
|
1211
1164
|
for n in range(0, nstart):
|
|
1212
|
-
B.
|
|
1165
|
+
B.setRange(_q2(Cx, i, n, Ni, Nn), zero, zero)
|
|
1213
1166
|
|
|
1214
1167
|
# Process noRothConversions option. Also valid when N_i == 1, why not?
|
|
1215
1168
|
if "noRothConversions" in options and options["noRothConversions"] != "None":
|
|
@@ -1220,15 +1173,63 @@ class Plan(object):
|
|
|
1220
1173
|
raise ValueError(f"Unknown individual {rhsopt} for noRothConversions:")
|
|
1221
1174
|
|
|
1222
1175
|
for n in range(Nn):
|
|
1223
|
-
B.
|
|
1176
|
+
B.setRange(_q2(Cx, i_x, n, Ni, Nn), zero, zero)
|
|
1177
|
+
|
|
1178
|
+
# Impose withdrawal limits on taxable and tax-exempt accounts.
|
|
1179
|
+
for i in range(Ni):
|
|
1180
|
+
for j in [0, 2]:
|
|
1181
|
+
for n in range(Nn):
|
|
1182
|
+
rowDic = {_q3(Cw, i, j, n, Ni, Nj, Nn): -1, _q3(Cb, i, j, n, Ni, Nj, Nn + 1): 1}
|
|
1183
|
+
A.addNewRow(rowDic, zero, inf)
|
|
1224
1184
|
|
|
1225
|
-
#
|
|
1185
|
+
# Impose withdrawals and conversion limits on tax-deferred account.
|
|
1186
|
+
for i in range(Ni):
|
|
1187
|
+
for n in range(Nn):
|
|
1188
|
+
rowDic = {
|
|
1189
|
+
_q2(Cx, i, n, Ni, Nn): -1,
|
|
1190
|
+
_q3(Cw, i, 1, n, Ni, Nj, Nn): -1,
|
|
1191
|
+
_q3(Cb, i, 1, n, Ni, Nj, Nn + 1): 1,
|
|
1192
|
+
}
|
|
1193
|
+
A.addNewRow(rowDic, zero, inf)
|
|
1194
|
+
|
|
1195
|
+
# Constraints depending on objective function.
|
|
1196
|
+
if objective == "maxSpending":
|
|
1197
|
+
# Impose optional constraint on final bequest requested in today's $.
|
|
1198
|
+
if "bequest" in options:
|
|
1199
|
+
bequest = options["bequest"]
|
|
1200
|
+
assert isinstance(bequest, (int, float)), "Desired bequest is not a number."
|
|
1201
|
+
bequest *= units * self.gamma_n[-1]
|
|
1202
|
+
else:
|
|
1203
|
+
# If not specified, defaults to $1 (nominal $).
|
|
1204
|
+
bequest = 1
|
|
1205
|
+
|
|
1206
|
+
row = A.newRow()
|
|
1207
|
+
for i in range(Ni):
|
|
1208
|
+
row.addElem(_q3(Cb, i, 0, Nn, Ni, Nj, Nn + 1), 1)
|
|
1209
|
+
row.addElem(_q3(Cb, i, 1, Nn, Ni, Nj, Nn + 1), 1 - self.nu)
|
|
1210
|
+
# Nudge could be added (e.g. 1.02) to artificially favor tax-exempt account
|
|
1211
|
+
# as heirs's benefits of 10y tax-free is not weighted in?
|
|
1212
|
+
row.addElem(_q3(Cb, i, 2, Nn, Ni, Nj, Nn + 1), 1)
|
|
1213
|
+
A.addRow(row, bequest, bequest)
|
|
1214
|
+
# self.mylog.vprint('Adding bequest constraint of:', u.d(bequest))
|
|
1215
|
+
elif objective == "maxBequest":
|
|
1216
|
+
spending = options["netSpending"]
|
|
1217
|
+
assert isinstance(spending, (int, float)), "Desired spending provided is not a number."
|
|
1218
|
+
# Account for time elapsed in the current year.
|
|
1219
|
+
spending *= units * self.yearFracLeft
|
|
1220
|
+
# self.mylog.vprint('Maximizing bequest with desired net spending of:', u.d(spending))
|
|
1221
|
+
# To allow slack in first year, Cg can be made Nn+1 and store basis in g[Nn].
|
|
1222
|
+
# A.addNewRow({_q1(Cg, 0, Nn): 1}, spending, spending)
|
|
1223
|
+
B.setRange(_q1(Cg, 0, Nn), spending, spending)
|
|
1224
|
+
|
|
1225
|
+
# Set initial balances through bounds or constraints.
|
|
1226
1226
|
for i in range(Ni):
|
|
1227
1227
|
for j in range(Nj):
|
|
1228
1228
|
rhs = self.beta_ij[i, j]
|
|
1229
|
-
A.addNewRow({_q3(Cb, i, j, 0, Ni, Nj, Nn + 1): 1}, rhs, rhs)
|
|
1229
|
+
# A.addNewRow({_q3(Cb, i, j, 0, Ni, Nj, Nn + 1): 1}, rhs, rhs)
|
|
1230
|
+
B.setRange(_q3(Cb, i, j, 0, Ni, Nj, Nn + 1), rhs, rhs)
|
|
1230
1231
|
|
|
1231
|
-
# Link
|
|
1232
|
+
# Link surplus and taxable account deposits regardless of Ni.
|
|
1232
1233
|
for i in range(Ni):
|
|
1233
1234
|
fac1 = u.krond(i, 0) * (1 - self.eta) + u.krond(i, 1) * self.eta
|
|
1234
1235
|
for n in range(n_d):
|
|
@@ -1240,7 +1241,20 @@ class Plan(object):
|
|
|
1240
1241
|
A.addNewRow(rowDic, zero, zero)
|
|
1241
1242
|
|
|
1242
1243
|
# No surplus allowed during the last year to be used as a tax loophole.
|
|
1243
|
-
B.
|
|
1244
|
+
B.setRange(_q1(Cs, Nn - 1, Nn), zero, zero)
|
|
1245
|
+
|
|
1246
|
+
if Ni == 2:
|
|
1247
|
+
# No conversion during last year.
|
|
1248
|
+
# B.setRange(_q2(Cx, i_d, nd-1, Ni, Nn), zero, zero)
|
|
1249
|
+
# B.setRange(_q2(Cx, i_s, Nn-1, Ni, Nn), zero, zero)
|
|
1250
|
+
|
|
1251
|
+
# No withdrawals or deposits for any i_d-owned accounts after year of passing.
|
|
1252
|
+
# Implicit n_d < Nn imposed by for loop.
|
|
1253
|
+
for n in range(n_d, Nn):
|
|
1254
|
+
B.setRange(_q2(Cd, i_d, n, Ni, Nn), zero, zero)
|
|
1255
|
+
B.setRange(_q2(Cx, i_d, n, Ni, Nn), zero, zero)
|
|
1256
|
+
for j in range(Nj):
|
|
1257
|
+
B.setRange(_q3(Cw, i_d, j, n, Ni, Nj, Nn), zero, zero)
|
|
1244
1258
|
|
|
1245
1259
|
# Account balances carried from year to year.
|
|
1246
1260
|
# Considering spousal asset transfer at passing of a spouse.
|
|
@@ -1248,45 +1262,44 @@ class Plan(object):
|
|
|
1248
1262
|
for i in range(Ni):
|
|
1249
1263
|
for j in range(Nj):
|
|
1250
1264
|
for n in range(Nn):
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1265
|
+
if Ni == 2 and n_d < Nn and i == i_d and n == n_d - 1:
|
|
1266
|
+
# fac1 = 1 - (u.krond(n, n_d - 1) * u.krond(i, i_d))
|
|
1267
|
+
fac1 = 0
|
|
1268
|
+
else:
|
|
1269
|
+
fac1 = 1
|
|
1270
|
+
|
|
1254
1271
|
rhs = fac1 * self.kappa_ijn[i, j, n] * Tauh_ijn[i, j, n]
|
|
1255
1272
|
|
|
1256
1273
|
row = A.newRow()
|
|
1257
1274
|
row.addElem(_q3(Cb, i, j, n + 1, Ni, Nj, Nn + 1), 1)
|
|
1258
|
-
row.addElem(_q3(Cb, i, j, n, Ni, Nj, Nn + 1), -
|
|
1259
|
-
row.addElem(
|
|
1260
|
-
row.addElem(
|
|
1275
|
+
row.addElem(_q3(Cb, i, j, n, Ni, Nj, Nn + 1), -fac1 * Tau1_ijn[i, j, n])
|
|
1276
|
+
row.addElem(_q3(Cw, i, j, n, Ni, Nj, Nn), fac1 * Tau1_ijn[i, j, n])
|
|
1277
|
+
row.addElem(_q2(Cd, i, n, Ni, Nn), -fac1 * u.krond(j, 0) * Tau1_ijn[i, 0, n])
|
|
1261
1278
|
row.addElem(
|
|
1262
1279
|
_q2(Cx, i, n, Ni, Nn),
|
|
1263
|
-
-(u.krond(j, 2) - u.krond(j, 1)) *
|
|
1280
|
+
-fac1 * (u.krond(j, 2) - u.krond(j, 1)) * Tau1_ijn[i, j, n],
|
|
1264
1281
|
)
|
|
1265
1282
|
|
|
1266
1283
|
if Ni == 2 and n_d < Nn and i == i_s and n == n_d - 1:
|
|
1267
1284
|
fac2 = self.phi_j[j]
|
|
1268
|
-
fac2_idjn = fac2 * Tau1_ijn[i_d, j, n]
|
|
1269
1285
|
rhs += fac2 * self.kappa_ijn[i_d, j, n] * Tauh_ijn[i_d, j, n]
|
|
1270
|
-
|
|
1271
|
-
row.addElem(_q3(
|
|
1272
|
-
row.addElem(_q2(Cd, i_d, n, Ni, Nn), -u.krond(j, 0) *
|
|
1273
|
-
row.addElem(_q3(Cw, i_d, j, n, Ni, Nj, Nn), fac2_idjn)
|
|
1286
|
+
row.addElem(_q3(Cb, i_d, j, n, Ni, Nj, Nn + 1), -fac2 * Tau1_ijn[i_d, j, n])
|
|
1287
|
+
row.addElem(_q3(Cw, i_d, j, n, Ni, Nj, Nn), fac2 * Tau1_ijn[i_d, j, n])
|
|
1288
|
+
row.addElem(_q2(Cd, i_d, n, Ni, Nn), -fac2 * u.krond(j, 0) * Tau1_ijn[i_d, 0, n])
|
|
1274
1289
|
row.addElem(
|
|
1275
1290
|
_q2(Cx, i_d, n, Ni, Nn),
|
|
1276
|
-
-(u.krond(j, 2) - u.krond(j, 1)) *
|
|
1291
|
+
-fac2 * (u.krond(j, 2) - u.krond(j, 1)) * Tau1_ijn[i_d, j, n],
|
|
1277
1292
|
)
|
|
1278
1293
|
A.addRow(row, rhs, rhs)
|
|
1279
1294
|
|
|
1280
1295
|
tau_0prev = np.roll(self.tau_kn[0, :], 1)
|
|
1281
|
-
# No tax on losses, nor tax-loss harvesting.
|
|
1282
1296
|
tau_0prev[tau_0prev < 0] = 0
|
|
1283
1297
|
|
|
1284
|
-
#
|
|
1298
|
+
# Net cash flow.
|
|
1285
1299
|
for n in range(Nn):
|
|
1286
|
-
rhs =
|
|
1287
|
-
row = A.newRow()
|
|
1288
|
-
row.addElem(_q1(
|
|
1289
|
-
row.addElem(_q1(Cm, n, Nn), 1)
|
|
1300
|
+
rhs = -self.M_n[n]
|
|
1301
|
+
row = A.newRow({_q1(Cg, n, Nn): 1})
|
|
1302
|
+
row.addElem(_q1(Cs, n, Nn), 1)
|
|
1290
1303
|
for i in range(Ni):
|
|
1291
1304
|
fac = self.psi * self.alpha_ijkn[i, 0, 0, n]
|
|
1292
1305
|
rhs += (
|
|
@@ -1298,13 +1311,13 @@ class Plan(object):
|
|
|
1298
1311
|
)
|
|
1299
1312
|
|
|
1300
1313
|
row.addElem(_q3(Cb, i, 0, n, Ni, Nj, Nn + 1), fac * self.mu)
|
|
1301
|
-
row.addElem(_q2(Cd, i, n, Ni, Nn), 1 + fac * self.mu)
|
|
1302
1314
|
# Minus capital gains on taxable withdrawals using last year's rate if >=0.
|
|
1303
1315
|
# Plus taxable account withdrawals, and all other withdrawals.
|
|
1304
|
-
row.addElem(_q3(Cw, i, 0, n, Ni, Nj, Nn),
|
|
1316
|
+
row.addElem(_q3(Cw, i, 0, n, Ni, Nj, Nn), fac * (tau_0prev[n] - self.mu) - 1)
|
|
1305
1317
|
penalty = 0.1 if n < self.n59[i] else 0
|
|
1306
1318
|
row.addElem(_q3(Cw, i, 1, n, Ni, Nj, Nn), -1 + penalty)
|
|
1307
1319
|
row.addElem(_q3(Cw, i, 2, n, Ni, Nj, Nn), -1 + penalty)
|
|
1320
|
+
row.addElem(_q2(Cd, i, n, Ni, Nn), fac * self.mu)
|
|
1308
1321
|
|
|
1309
1322
|
# Minus tax on ordinary income, T_n.
|
|
1310
1323
|
for t in range(Nt):
|
|
@@ -1312,10 +1325,10 @@ class Plan(object):
|
|
|
1312
1325
|
|
|
1313
1326
|
A.addRow(row, rhs, rhs)
|
|
1314
1327
|
|
|
1315
|
-
#
|
|
1328
|
+
# Impose income profile.
|
|
1316
1329
|
for n in range(1, Nn):
|
|
1317
|
-
rowDic = {_q1(Cg, 0, Nn):
|
|
1318
|
-
A.addNewRow(rowDic,
|
|
1330
|
+
rowDic = {_q1(Cg, 0, Nn): spLo * self.xiBar_n[n], _q1(Cg, n, Nn): -self.xiBar_n[0]}
|
|
1331
|
+
A.addNewRow(rowDic, -inf, zero)
|
|
1319
1332
|
rowDic = {_q1(Cg, 0, Nn): spHi * self.xiBar_n[n], _q1(Cg, n, Nn): -self.xiBar_n[0]}
|
|
1320
1333
|
A.addNewRow(rowDic, zero, inf)
|
|
1321
1334
|
|
|
@@ -1331,102 +1344,33 @@ class Plan(object):
|
|
|
1331
1344
|
row.addElem(_q2(Cx, i, n, Ni, Nn), -1)
|
|
1332
1345
|
|
|
1333
1346
|
# Taxable returns on securities in taxable account.
|
|
1334
|
-
|
|
1335
|
-
rhs += 0.5 *
|
|
1336
|
-
row.addElem(_q3(Cb, i, 0, n, Ni, Nj, Nn + 1), -
|
|
1337
|
-
row.addElem(_q3(Cw, i, 0, n, Ni, Nj, Nn),
|
|
1338
|
-
row.addElem(_q2(Cd, i, n, Ni, Nn), -
|
|
1347
|
+
fak = np.sum(self.tau_kn[1:Nk, n] * self.alpha_ijkn[i, 0, 1:Nk, n], axis=0)
|
|
1348
|
+
rhs += 0.5 * fak * self.kappa_ijn[i, 0, n]
|
|
1349
|
+
row.addElem(_q3(Cb, i, 0, n, Ni, Nj, Nn + 1), -fak)
|
|
1350
|
+
row.addElem(_q3(Cw, i, 0, n, Ni, Nj, Nn), fak)
|
|
1351
|
+
row.addElem(_q2(Cd, i, n, Ni, Nn), -fak)
|
|
1339
1352
|
|
|
1340
1353
|
for t in range(Nt):
|
|
1341
1354
|
row.addElem(_q2(CF, t, n, Nt, Nn), 1)
|
|
1342
1355
|
|
|
1343
1356
|
A.addRow(row, rhs, rhs)
|
|
1344
1357
|
|
|
1345
|
-
#
|
|
1346
|
-
if options.get("withMedicare", True):
|
|
1347
|
-
nm, L_nq, C_nq = tx.mediVals(self.yobs, self.horizons, self.gamma_n, Nn, Nq)
|
|
1348
|
-
for n in range(Nn):
|
|
1349
|
-
# SOS1 constraint: 1 for n < nm otherwise all zero.
|
|
1350
|
-
row = A.newRow()
|
|
1351
|
-
for q in range(Nq):
|
|
1352
|
-
B.setBinary(_q2(Czm, n, q, Nn, Nq))
|
|
1353
|
-
row.addElem(_q2(Czm, n, q, Nn, Nq), 1)
|
|
1354
|
-
val = 0 if n < nm else 1
|
|
1355
|
-
A.addRow(row, val, val)
|
|
1356
|
-
|
|
1357
|
-
# Medicare costs calculations.
|
|
1358
|
-
row = A.newRow()
|
|
1359
|
-
row.addElem(_q1(Cm, n, Nn), 1)
|
|
1360
|
-
for q in range(Nq):
|
|
1361
|
-
row.addElem(_q2(Czm, n, q, Nn, Nq), -C_nq[n, q])
|
|
1362
|
-
A.addRow(row, zero, zero)
|
|
1363
|
-
|
|
1364
|
-
largeM = 2*L_nq[n, Nq]
|
|
1365
|
-
# Medicare brackets calculations.
|
|
1366
|
-
if n >= nm:
|
|
1367
|
-
# Lower bound.
|
|
1368
|
-
row = A.newRow()
|
|
1369
|
-
rhs = 0
|
|
1370
|
-
for q in range(0, Nq):
|
|
1371
|
-
row.addElem(_q2(Czm, n, q, Nn, Nq), L_nq[n, q])
|
|
1372
|
-
if n > 2 or (n == 2 and self.yearFracLeft == 1):
|
|
1373
|
-
for i in range(Ni):
|
|
1374
|
-
fac = (self.mu * self.alpha_ijkn[i, 0, 0, n-2]
|
|
1375
|
-
+ np.sum(self.alpha_ijkn[i, 0, 1:, n-2] * self.tau_kn[1:, n-2], axis=0))
|
|
1376
|
-
|
|
1377
|
-
row.addElem(_q3(Cb, i, 0, n-2, Ni, Nj, Nn + 1), -fac)
|
|
1378
|
-
row.addElem(_q2(Cd, i, n-2, Ni, Nn), -fac)
|
|
1379
|
-
row.addElem(_q3(Cw, i, 0, n-2, Ni, Nj, Nn),
|
|
1380
|
-
fac - self.alpha_ijkn[i, 0, 0, n-2]*max(0, self.tau_kn[0, min(0, n-3)]))
|
|
1381
|
-
row.addElem(_q3(Cw, i, 1, n-2, Ni, Nj, Nn), -1)
|
|
1382
|
-
row.addElem(_q2(Cx, i, n-2, Ni, Nn), -1)
|
|
1383
|
-
rhs += self.omega_in[i, n-2] + 0.85*self.zetaBar_in[i, n-2] + self.piBar_in[i, n-2]
|
|
1384
|
-
rhs += 0.5*self.kappa_ijn[i, 0, n-2] * fac
|
|
1385
|
-
else:
|
|
1386
|
-
rhs = self.prevMAGIs[n]
|
|
1387
|
-
|
|
1388
|
-
A.addRow(row, -largeM, rhs)
|
|
1389
|
-
|
|
1390
|
-
# Upper bound. Lots of duplication. Can be merged with lower bound at one point.
|
|
1391
|
-
row = A.newRow()
|
|
1392
|
-
rhs = largeM
|
|
1393
|
-
for q in range(0, Nq):
|
|
1394
|
-
row.addElem(_q2(Czm, n, q, Nn, Nq), largeM - L_nq[n, q+1])
|
|
1395
|
-
if n > 2 or (n == 2 and self.yearFracLeft == 1):
|
|
1396
|
-
for i in range(Ni):
|
|
1397
|
-
fac = (self.mu * self.alpha_ijkn[i, 0, 0, n-2]
|
|
1398
|
-
+ np.sum(self.alpha_ijkn[i, 0, 1:, n-2] * self.tau_kn[1:, n-2], axis=0))
|
|
1399
|
-
|
|
1400
|
-
row.addElem(_q3(Cb, i, 0, n-2, Ni, Nj, Nn + 1), +fac)
|
|
1401
|
-
row.addElem(_q2(Cd, i, n-2, Ni, Nn), +fac)
|
|
1402
|
-
row.addElem(_q3(Cw, i, 0, n-2, Ni, Nj, Nn),
|
|
1403
|
-
-fac + self.alpha_ijkn[i, 0, 0, n-2]*max(0, self.tau_kn[0, min(0, n-3)]))
|
|
1404
|
-
row.addElem(_q3(Cw, i, 1, n-2, Ni, Nj, Nn), +1)
|
|
1405
|
-
row.addElem(_q2(Cx, i, n-2, Ni, Nn), +1)
|
|
1406
|
-
rhs -= self.omega_in[i, n-2] + 0.85*self.zetaBar_in[i, n-2] + self.piBar_in[i, n-2]
|
|
1407
|
-
rhs -= 0.5*self.kappa_ijn[i, 0, n-2] * fac
|
|
1408
|
-
else:
|
|
1409
|
-
rhs = largeM - self.prevMAGIs[n]
|
|
1410
|
-
|
|
1411
|
-
A.addRow(row, zero, rhs)
|
|
1412
|
-
|
|
1413
|
-
# Set mutual exclusions of withdrawals, conversions, and deposits.
|
|
1358
|
+
# Configure binary variables.
|
|
1414
1359
|
for i in range(Ni):
|
|
1415
1360
|
for n in range(self.horizons[i]):
|
|
1416
|
-
#
|
|
1417
|
-
|
|
1418
|
-
B.setBinary(_q3(Czx, i, n, z, Ni, Nn, Nz))
|
|
1361
|
+
# for z in range(Nz):
|
|
1362
|
+
# B.setBinary(_q3(Cz, i, n, z, Ni, Nn, Nz))
|
|
1419
1363
|
|
|
1420
1364
|
# Exclude simultaneous deposits and withdrawals from taxable or tax-free accounts.
|
|
1421
1365
|
A.addNewRow(
|
|
1422
|
-
{_q3(
|
|
1366
|
+
{_q3(Cz, i, n, 0, Ni, Nn, Nz): bigM, _q1(Cs, n, Nn): -1},
|
|
1423
1367
|
zero,
|
|
1424
1368
|
bigM,
|
|
1425
1369
|
)
|
|
1426
1370
|
|
|
1427
1371
|
A.addNewRow(
|
|
1428
1372
|
{
|
|
1429
|
-
_q3(
|
|
1373
|
+
_q3(Cz, i, n, 0, Ni, Nn, Nz): bigM,
|
|
1430
1374
|
_q3(Cw, i, 0, n, Ni, Nj, Nn): 1,
|
|
1431
1375
|
_q3(Cw, i, 2, n, Ni, Nj, Nn): 1,
|
|
1432
1376
|
},
|
|
@@ -1436,17 +1380,21 @@ class Plan(object):
|
|
|
1436
1380
|
|
|
1437
1381
|
# Exclude simultaneous Roth conversions and tax-exempt withdrawals.
|
|
1438
1382
|
A.addNewRow(
|
|
1439
|
-
{_q3(
|
|
1383
|
+
{_q3(Cz, i, n, 1, Ni, Nn, Nz): bigM, _q2(Cx, i, n, Ni, Nn): -1},
|
|
1440
1384
|
zero,
|
|
1441
1385
|
bigM,
|
|
1442
1386
|
)
|
|
1443
1387
|
|
|
1444
1388
|
A.addNewRow(
|
|
1445
|
-
{_q3(
|
|
1389
|
+
{_q3(Cz, i, n, 1, Ni, Nn, Nz): bigM, _q3(Cw, i, 2, n, Ni, Nj, Nn): 1},
|
|
1446
1390
|
zero,
|
|
1447
1391
|
bigM,
|
|
1448
1392
|
)
|
|
1449
1393
|
|
|
1394
|
+
for n in range(self.horizons[i], Nn):
|
|
1395
|
+
B.setRange(_q3(Cz, i, n, 0, Ni, Nn, Nz), zero, zero)
|
|
1396
|
+
B.setRange(_q3(Cz, i, n, 1, Ni, Nn, Nz), zero, zero)
|
|
1397
|
+
|
|
1450
1398
|
# Now build a solver-neutral objective vector.
|
|
1451
1399
|
c = abc.Objective(self.nvars)
|
|
1452
1400
|
if objective == "maxSpending":
|
|
@@ -1668,7 +1616,7 @@ class Plan(object):
|
|
|
1668
1616
|
|
|
1669
1617
|
@_checkConfiguration
|
|
1670
1618
|
@_timer
|
|
1671
|
-
def solve(self, objective, options=
|
|
1619
|
+
def solve(self, objective, options={}):
|
|
1672
1620
|
"""
|
|
1673
1621
|
This function builds the necessary constaints and
|
|
1674
1622
|
runs the optimizer.
|
|
@@ -1693,7 +1641,8 @@ class Plan(object):
|
|
|
1693
1641
|
|
|
1694
1642
|
# Check objective and required options.
|
|
1695
1643
|
knownObjectives = ["maxBequest", "maxSpending"]
|
|
1696
|
-
knownSolvers = ["HiGHS", "MOSEK"]
|
|
1644
|
+
knownSolvers = ["HiGHS", "PuLP/CBC", "MOSEK"]
|
|
1645
|
+
|
|
1697
1646
|
knownOptions = [
|
|
1698
1647
|
"bequest",
|
|
1699
1648
|
"bigM",
|
|
@@ -1707,11 +1656,8 @@ class Plan(object):
|
|
|
1707
1656
|
"units",
|
|
1708
1657
|
"withMedicare",
|
|
1709
1658
|
]
|
|
1710
|
-
# We
|
|
1711
|
-
|
|
1712
|
-
myoptions = {}
|
|
1713
|
-
else:
|
|
1714
|
-
myoptions = dict(options)
|
|
1659
|
+
# We might modify options if required.
|
|
1660
|
+
myoptions = dict(options)
|
|
1715
1661
|
|
|
1716
1662
|
for opt in myoptions:
|
|
1717
1663
|
if opt not in knownOptions:
|
|
@@ -1734,41 +1680,104 @@ class Plan(object):
|
|
|
1734
1680
|
if objective == "maxSpending" and "bequest" not in myoptions:
|
|
1735
1681
|
self.mylog.vprint("Using bequest of $1.")
|
|
1736
1682
|
|
|
1737
|
-
self.
|
|
1683
|
+
self.prevMAGI = np.zeros(3)
|
|
1738
1684
|
if "previousMAGIs" in myoptions:
|
|
1739
1685
|
magi = myoptions["previousMAGIs"]
|
|
1740
1686
|
if len(magi) != 3:
|
|
1741
1687
|
raise ValueError("previousMAGIs must have 3 values.")
|
|
1742
1688
|
|
|
1743
1689
|
units = u.getUnits(options.get("units", "k"))
|
|
1744
|
-
self.
|
|
1690
|
+
self.prevMAGI = units * np.array(magi)
|
|
1745
1691
|
|
|
1746
|
-
|
|
1747
|
-
if
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
raise ValueError(f"Slack value out of range {lambdha}.")
|
|
1751
|
-
self.lambdha = lambdha / 100
|
|
1692
|
+
lambdha = myoptions.get("spendingSlack", 0)
|
|
1693
|
+
if lambdha < 0 or lambdha > 50:
|
|
1694
|
+
raise ValueError(f"Slack value out of range {lambdha}.")
|
|
1695
|
+
self.lambdha = lambdha / 100
|
|
1752
1696
|
|
|
1753
1697
|
self._adjustParameters()
|
|
1754
1698
|
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
raise ValueError(f"Unknown solver {solver}.")
|
|
1759
|
-
else:
|
|
1760
|
-
solver = self.defaultSolver
|
|
1699
|
+
solver = myoptions.get("solver", self.defaultSolver)
|
|
1700
|
+
if solver not in knownSolvers:
|
|
1701
|
+
raise ValueError(f"Unknown solver {solver}.")
|
|
1761
1702
|
|
|
1762
1703
|
if solver == "HiGHS":
|
|
1763
|
-
self._milpSolve
|
|
1704
|
+
solverMethod = self._milpSolve
|
|
1705
|
+
elif solver == "PuLP/CBC":
|
|
1706
|
+
solverMethod = self._pulpSolve
|
|
1764
1707
|
elif solver == "MOSEK":
|
|
1765
|
-
self._mosekSolve
|
|
1708
|
+
solverMethod = self._mosekSolve
|
|
1709
|
+
else:
|
|
1710
|
+
raise RuntimeError("Internal error in defining solverMethod.")
|
|
1711
|
+
|
|
1712
|
+
self._scSolve(objective, options, solverMethod)
|
|
1766
1713
|
|
|
1767
1714
|
self.objective = objective
|
|
1768
1715
|
self.solverOptions = myoptions
|
|
1769
1716
|
|
|
1770
1717
|
return None
|
|
1771
1718
|
|
|
1719
|
+
def _scSolve(self, objective, options, solverMethod):
|
|
1720
|
+
"""
|
|
1721
|
+
Self-consistent loop, regardless of solver.
|
|
1722
|
+
"""
|
|
1723
|
+
withMedicare = options.get("withMedicare", True)
|
|
1724
|
+
|
|
1725
|
+
if objective == "maxSpending":
|
|
1726
|
+
objFac = -1 / self.xi_n[0]
|
|
1727
|
+
else:
|
|
1728
|
+
objFac = -1 / self.gamma_n[-1]
|
|
1729
|
+
|
|
1730
|
+
it = 0
|
|
1731
|
+
absdiff = np.inf
|
|
1732
|
+
old_x = np.zeros(self.nvars)
|
|
1733
|
+
old_solutions = [np.inf]
|
|
1734
|
+
self._estimateMedicare(None, withMedicare)
|
|
1735
|
+
while True:
|
|
1736
|
+
solution, xx, solverSuccess, solverMsg = solverMethod(objective, options)
|
|
1737
|
+
|
|
1738
|
+
if not solverSuccess:
|
|
1739
|
+
break
|
|
1740
|
+
|
|
1741
|
+
if not withMedicare:
|
|
1742
|
+
break
|
|
1743
|
+
|
|
1744
|
+
self._estimateMedicare(xx)
|
|
1745
|
+
|
|
1746
|
+
self.mylog.vprint(f"Iteration: {it} objective: {u.d(solution * objFac, f=2)}")
|
|
1747
|
+
|
|
1748
|
+
delta = xx - old_x
|
|
1749
|
+
absdiff = np.sum(np.abs(delta), axis=0)
|
|
1750
|
+
if absdiff < 1:
|
|
1751
|
+
self.mylog.vprint("Converged on full solution.")
|
|
1752
|
+
break
|
|
1753
|
+
|
|
1754
|
+
# Avoid oscillatory solutions. Look only at most recent solutions. Within $10.
|
|
1755
|
+
isclosenough = abs(-solution - min(old_solutions[int(it / 2) :])) < 10 * self.xi_n[0]
|
|
1756
|
+
if isclosenough:
|
|
1757
|
+
self.mylog.vprint("Converged through selecting minimum oscillating objective.")
|
|
1758
|
+
break
|
|
1759
|
+
|
|
1760
|
+
if it > 59:
|
|
1761
|
+
self.mylog.vprint("WARNING: Exiting loop on maximum iterations.")
|
|
1762
|
+
break
|
|
1763
|
+
|
|
1764
|
+
old_solutions.append(-solution)
|
|
1765
|
+
old_x = xx
|
|
1766
|
+
|
|
1767
|
+
if solverSuccess:
|
|
1768
|
+
self.mylog.vprint(f"Self-consistent Medicare loop returned after {it} iterations.")
|
|
1769
|
+
self.mylog.vprint(solverMsg)
|
|
1770
|
+
self.mylog.vprint(f"Objective: {u.d(solution * objFac)}")
|
|
1771
|
+
# self.mylog.vprint('Upper bound:', u.d(-solution.mip_dual_bound))
|
|
1772
|
+
self._aggregateResults(xx)
|
|
1773
|
+
self._timestamp = datetime.now().strftime("%Y-%m-%d at %H:%M:%S")
|
|
1774
|
+
self.caseStatus = "solved"
|
|
1775
|
+
else:
|
|
1776
|
+
self.mylog.vprint("WARNING: Optimization failed:", solverMsg, solverSuccess)
|
|
1777
|
+
self.caseStatus = "unsuccessful"
|
|
1778
|
+
|
|
1779
|
+
return None
|
|
1780
|
+
|
|
1772
1781
|
def _milpSolve(self, objective, options):
|
|
1773
1782
|
"""
|
|
1774
1783
|
Solve problem using scipy HiGHS solver.
|
|
@@ -1786,27 +1795,74 @@ class Plan(object):
|
|
|
1786
1795
|
|
|
1787
1796
|
bounds = optimize.Bounds(Lb, Ub)
|
|
1788
1797
|
constraint = optimize.LinearConstraint(Alu, lbvec, ubvec)
|
|
1789
|
-
solution = optimize.milp(
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1798
|
+
solution = optimize.milp(
|
|
1799
|
+
c,
|
|
1800
|
+
integrality=integrality,
|
|
1801
|
+
constraints=constraint,
|
|
1802
|
+
bounds=bounds,
|
|
1803
|
+
options=milpOptions,
|
|
1804
|
+
)
|
|
1805
|
+
|
|
1806
|
+
return solution.fun, solution.x, solution.success, solution.message
|
|
1807
|
+
|
|
1808
|
+
def _pulpSolve(self, objective, options):
|
|
1809
|
+
"""
|
|
1810
|
+
Solve problem using scipy PuLP solver.
|
|
1811
|
+
"""
|
|
1812
|
+
import pulp
|
|
1813
|
+
|
|
1814
|
+
self._buildConstraints(objective, options)
|
|
1815
|
+
Alu, lbvec, ubvec = self.A.arrays()
|
|
1816
|
+
ckeys = self.A.keys()
|
|
1817
|
+
Lb, Ub = self.B.arrays()
|
|
1818
|
+
vkeys = self.B.keys()
|
|
1819
|
+
c = self.c.arrays()
|
|
1820
|
+
c_list = c.tolist()
|
|
1821
|
+
|
|
1822
|
+
prob = pulp.LpProblem(self._name.replace(" ", "_"), pulp.LpMinimize)
|
|
1823
|
+
|
|
1824
|
+
x = []
|
|
1825
|
+
for i in range(self.nvars - self.nbins):
|
|
1826
|
+
if vkeys[i] == "ra":
|
|
1827
|
+
x += [pulp.LpVariable(f"x_{i}", cat="Continuous", lowBound=Lb[i], upBound=Ub[i])]
|
|
1828
|
+
elif vkeys[i] == "lo":
|
|
1829
|
+
x += [pulp.LpVariable(f"x_{i}", cat="Continuous", lowBound=Lb[i], upBound=None)]
|
|
1830
|
+
elif vkeys[i] == "up":
|
|
1831
|
+
x += [pulp.LpVariable(f"x_{i}", cat="Continuous", lowBound=None, upBound=Ub[i])]
|
|
1832
|
+
elif vkeys[i] == "fr":
|
|
1833
|
+
x += [pulp.LpVariable(f"x_{i}", cat="Continuous", lowBound=None, upBound=None)]
|
|
1834
|
+
elif vkeys[i] == "fx":
|
|
1835
|
+
x += [pulp.LpVariable(f"x_{i}", cat="Continuous", lowBound=Lb[i], upBound=Ub[i])]
|
|
1797
1836
|
else:
|
|
1798
|
-
|
|
1837
|
+
raise RuntimeError(f"Internal error: Variable with wierd bound f{vkeys[i]}.")
|
|
1799
1838
|
|
|
1800
|
-
|
|
1801
|
-
# self.mylog.vprint('Upper bound:', u.d(-solution.mip_dual_bound))
|
|
1802
|
-
self._aggregateResults(options, solution.x)
|
|
1803
|
-
self._timestamp = datetime.now().strftime("%Y-%m-%d at %H:%M:%S")
|
|
1804
|
-
self.caseStatus = "solved"
|
|
1805
|
-
else:
|
|
1806
|
-
self.mylog.vprint("WARNING: Optimization failed:", solution.message, solution.success)
|
|
1807
|
-
self.caseStatus = "unsuccessful"
|
|
1839
|
+
x.extend([pulp.LpVariable(f"z_{i}", cat="Binary") for i in range(self.nbins)])
|
|
1808
1840
|
|
|
1809
|
-
|
|
1841
|
+
prob += pulp.lpDot(c_list, x)
|
|
1842
|
+
|
|
1843
|
+
for r in range(self.A.ncons):
|
|
1844
|
+
row = Alu[r].tolist()
|
|
1845
|
+
if ckeys[r] in ["lo", "ra"] and lbvec[r] != -np.inf:
|
|
1846
|
+
prob += pulp.lpDot(row, x) >= lbvec[r]
|
|
1847
|
+
if ckeys[r] in ["up", "ra"] and ubvec[r] != np.inf:
|
|
1848
|
+
prob += pulp.lpDot(row, x) <= ubvec[r]
|
|
1849
|
+
if ckeys[r] == "fx":
|
|
1850
|
+
prob += pulp.lpDot(row, x) == ubvec[r]
|
|
1851
|
+
|
|
1852
|
+
# prob.writeLP("C:\\Users\\marti\\Downloads\\pulp.lp")
|
|
1853
|
+
# prob.writeMPS("C:\\Users\\marti\\Downloads\\pulp.mps", rename=True)
|
|
1854
|
+
# solver_list = pulp.listSolvers(onlyAvailable=True)
|
|
1855
|
+
# print("Available solvers:", solver_list)
|
|
1856
|
+
# solver = pulp.getSolver("MOSEK")
|
|
1857
|
+
# prob.solve(solver)
|
|
1858
|
+
|
|
1859
|
+
prob.solve(pulp.PULP_CBC_CMD(msg=False))
|
|
1860
|
+
# Filter out None values and convert to array.
|
|
1861
|
+
xx = np.array([0 if x[i].varValue is None else x[i].varValue for i in range(self.nvars)])
|
|
1862
|
+
solution = np.dot(c, xx)
|
|
1863
|
+
success = (pulp.LpStatus[prob.status] == "Optimal")
|
|
1864
|
+
|
|
1865
|
+
return solution, xx, success, pulp.LpStatus[prob.status]
|
|
1810
1866
|
|
|
1811
1867
|
def _mosekSolve(self, objective, options):
|
|
1812
1868
|
"""
|
|
@@ -1822,6 +1878,11 @@ class Plan(object):
|
|
|
1822
1878
|
"up": mosek.boundkey.up,
|
|
1823
1879
|
}
|
|
1824
1880
|
|
|
1881
|
+
solverMsg = str()
|
|
1882
|
+
|
|
1883
|
+
def _streamPrinter(text, msg=solverMsg):
|
|
1884
|
+
msg += text
|
|
1885
|
+
|
|
1825
1886
|
self._buildConstraints(objective, options)
|
|
1826
1887
|
Aind, Aval, clb, cub = self.A.lists()
|
|
1827
1888
|
ckeys = self.A.keys()
|
|
@@ -1831,7 +1892,7 @@ class Plan(object):
|
|
|
1831
1892
|
cind, cval = self.c.lists()
|
|
1832
1893
|
|
|
1833
1894
|
task = mosek.Task()
|
|
1834
|
-
# task.putdouparam(mosek.dparam.mio_rel_gap_const, 1e-
|
|
1895
|
+
# task.putdouparam(mosek.dparam.mio_rel_gap_const, 1e-6)
|
|
1835
1896
|
# task.putdouparam(mosek.dparam.mio_tol_abs_relax_int, 1e-4)
|
|
1836
1897
|
# task.set_Stream(mosek.streamtype.msg, _streamPrinter)
|
|
1837
1898
|
task.appendcons(self.A.ncons)
|
|
@@ -1853,33 +1914,17 @@ class Plan(object):
|
|
|
1853
1914
|
task.putobjsense(mosek.objsense.minimize)
|
|
1854
1915
|
task.optimize()
|
|
1855
1916
|
|
|
1917
|
+
# Problem MUST contain binary variables to make these calls.
|
|
1856
1918
|
solsta = task.getsolsta(mosek.soltype.itg)
|
|
1857
|
-
|
|
1919
|
+
solverSuccess = (solsta == mosek.solsta.integer_optimal)
|
|
1858
1920
|
|
|
1921
|
+
xx = np.array(task.getxx(mosek.soltype.itg))
|
|
1922
|
+
solution = task.getprimalobj(mosek.soltype.itg)
|
|
1859
1923
|
task.set_Stream(mosek.streamtype.wrn, _streamPrinter)
|
|
1924
|
+
task.solutionsummary(mosek.streamtype.msg)
|
|
1860
1925
|
# task.writedata(self._name+'.ptf')
|
|
1861
|
-
if solsta == mosek.solsta.integer_optimal:
|
|
1862
|
-
xx = np.array(task.getxx(mosek.soltype.itg))
|
|
1863
|
-
solution = task.getprimalobj(mosek.soltype.itg)
|
|
1864
|
-
|
|
1865
|
-
self.mylog.vprint("Solution successful.")
|
|
1866
|
-
task.solutionsummary(mosek.streamtype.msg)
|
|
1867
|
-
if objective == "maxSpending":
|
|
1868
|
-
objFac = -1 / self.xi_n[0]
|
|
1869
|
-
else:
|
|
1870
|
-
objFac = -1 / self.gamma_n[-1]
|
|
1871
1926
|
|
|
1872
|
-
|
|
1873
|
-
self.caseStatus = "solved"
|
|
1874
|
-
# self.mylog.vprint('Upper bound:', u.d(-solution.mip_dual_bound))
|
|
1875
|
-
self._aggregateResults(options, xx)
|
|
1876
|
-
self._timestamp = datetime.now().strftime("%Y/%m/%d %H:%M:%S")
|
|
1877
|
-
else:
|
|
1878
|
-
self.mylog.vprint("WARNING: Optimization failed:", "Infeasible or unbounded.")
|
|
1879
|
-
task.solutionsummary(mosek.streamtype.msg)
|
|
1880
|
-
self.caseStatus = "unsuccessful"
|
|
1881
|
-
|
|
1882
|
-
return None
|
|
1927
|
+
return solution, xx, solverSuccess, solverMsg
|
|
1883
1928
|
|
|
1884
1929
|
def _estimateMedicare(self, x=None, withMedicare=True):
|
|
1885
1930
|
"""
|
|
@@ -1896,11 +1941,11 @@ class Plan(object):
|
|
|
1896
1941
|
self.F_tn = self.F_tn.reshape((self.N_t, self.N_n))
|
|
1897
1942
|
MAGI_n = np.sum(self.F_tn, axis=0) + np.array(x[self.C["e"] : self.C["F"]])
|
|
1898
1943
|
|
|
1899
|
-
self.M_n = tx.mediCosts(self.yobs, self.horizons, MAGI_n, self.
|
|
1944
|
+
self.M_n = tx.mediCosts(self.yobs, self.horizons, MAGI_n, self.prevMAGI, self.gamma_n[:-1], self.N_n)
|
|
1900
1945
|
|
|
1901
1946
|
return None
|
|
1902
1947
|
|
|
1903
|
-
def _aggregateResults(self,
|
|
1948
|
+
def _aggregateResults(self, x):
|
|
1904
1949
|
"""
|
|
1905
1950
|
Utility function to aggregate results from solver.
|
|
1906
1951
|
Process all results from solution vector.
|
|
@@ -1910,7 +1955,6 @@ class Plan(object):
|
|
|
1910
1955
|
Nj = self.N_j
|
|
1911
1956
|
Nk = self.N_k
|
|
1912
1957
|
Nn = self.N_n
|
|
1913
|
-
Nq = self.N_q
|
|
1914
1958
|
Nt = self.N_t
|
|
1915
1959
|
# Nz = self.N_z
|
|
1916
1960
|
n_d = self.n_d
|
|
@@ -1920,12 +1964,10 @@ class Plan(object):
|
|
|
1920
1964
|
Ce = self.C["e"]
|
|
1921
1965
|
CF = self.C["F"]
|
|
1922
1966
|
Cg = self.C["g"]
|
|
1923
|
-
Cm = self.C["m"]
|
|
1924
1967
|
Cs = self.C["s"]
|
|
1925
1968
|
Cw = self.C["w"]
|
|
1926
1969
|
Cx = self.C["x"]
|
|
1927
|
-
|
|
1928
|
-
Czm = self.C["zm"]
|
|
1970
|
+
Cz = self.C["z"]
|
|
1929
1971
|
|
|
1930
1972
|
x = u.roundCents(x)
|
|
1931
1973
|
|
|
@@ -1944,28 +1986,19 @@ class Plan(object):
|
|
|
1944
1986
|
self.F_tn = np.array(x[CF:Cg])
|
|
1945
1987
|
self.F_tn = self.F_tn.reshape((Nt, Nn))
|
|
1946
1988
|
|
|
1947
|
-
self.g_n = np.array(x[Cg:
|
|
1948
|
-
|
|
1949
|
-
if options.get("withMedicare", True):
|
|
1950
|
-
self.m_n = np.array(x[Cm:Cs])
|
|
1951
|
-
else:
|
|
1952
|
-
self.m_n = np.zeros(Nn)
|
|
1989
|
+
self.g_n = np.array(x[Cg:Cs])
|
|
1953
1990
|
|
|
1954
1991
|
self.s_n = np.array(x[Cs:Cw])
|
|
1955
1992
|
|
|
1956
1993
|
self.w_ijn = np.array(x[Cw:Cx])
|
|
1957
1994
|
self.w_ijn = self.w_ijn.reshape((Ni, Nj, Nn))
|
|
1958
1995
|
|
|
1959
|
-
self.x_in = np.array(x[Cx:
|
|
1996
|
+
self.x_in = np.array(x[Cx:Cz])
|
|
1960
1997
|
self.x_in = self.x_in.reshape((Ni, Nn))
|
|
1961
1998
|
|
|
1962
|
-
# self.
|
|
1963
|
-
# self.
|
|
1964
|
-
# print(self.
|
|
1965
|
-
|
|
1966
|
-
if options.get("withMedicare", True):
|
|
1967
|
-
self.zm_nq = np.array(x[Czm:])
|
|
1968
|
-
self.zm_nq = self.zm_nq.reshape((Nn, Nq))
|
|
1999
|
+
# self.z_inz = np.array(x[Cz:])
|
|
2000
|
+
# self.z_inz = self.z_inz.reshape((Ni, Nn, Nz))
|
|
2001
|
+
# print(self.z_inz)
|
|
1969
2002
|
|
|
1970
2003
|
# Partial distribution at the passing of first spouse.
|
|
1971
2004
|
if Ni == 2 and n_d < Nn:
|
|
@@ -2154,10 +2187,10 @@ class Plan(object):
|
|
|
2154
2187
|
dic["Total tax paid on gains and dividends"] = f"{u.d(taxPaidNow)}"
|
|
2155
2188
|
dic["[Total tax paid on gains and dividends]"] = f"{u.d(taxPaid)}"
|
|
2156
2189
|
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
dic["Total Medicare premiums paid"] = f"{u.d(
|
|
2160
|
-
dic["[Total Medicare premiums paid]"] = f"{u.d(
|
|
2190
|
+
taxPaid = np.sum(self.M_n, axis=0)
|
|
2191
|
+
taxPaidNow = np.sum(self.M_n / self.gamma_n[:-1], axis=0)
|
|
2192
|
+
dic["Total Medicare premiums paid"] = f"{u.d(taxPaidNow)}"
|
|
2193
|
+
dic["[Total Medicare premiums paid]"] = f"{u.d(taxPaid)}"
|
|
2161
2194
|
|
|
2162
2195
|
if self.N_i == 2 and self.n_d < self.N_n:
|
|
2163
2196
|
p_j = self.partialEstate_j * (1 - self.phi_j)
|
|
@@ -2610,12 +2643,12 @@ class Plan(object):
|
|
|
2610
2643
|
style = {"income taxes": "-", "Medicare": "-."}
|
|
2611
2644
|
|
|
2612
2645
|
if value == "nominal":
|
|
2613
|
-
series = {"income taxes": self.T_n, "Medicare": self.
|
|
2646
|
+
series = {"income taxes": self.T_n, "Medicare": self.M_n}
|
|
2614
2647
|
yformat = "\\$k (nominal)"
|
|
2615
2648
|
else:
|
|
2616
2649
|
series = {
|
|
2617
2650
|
"income taxes": self.T_n / self.gamma_n[:-1],
|
|
2618
|
-
"Medicare": self.
|
|
2651
|
+
"Medicare": self.M_n / self.gamma_n[:-1],
|
|
2619
2652
|
}
|
|
2620
2653
|
yformat = "\\$k (" + str(self.year_n[0]) + "\\$)"
|
|
2621
2654
|
|
|
@@ -2750,7 +2783,7 @@ class Plan(object):
|
|
|
2750
2783
|
"net spending": self.g_n,
|
|
2751
2784
|
"taxable ord. income": self.G_n,
|
|
2752
2785
|
"taxable gains/divs": self.Q_n,
|
|
2753
|
-
"Tax bills + Med.": self.T_n + self.U_n + self.
|
|
2786
|
+
"Tax bills + Med.": self.T_n + self.U_n + self.M_n,
|
|
2754
2787
|
}
|
|
2755
2788
|
|
|
2756
2789
|
fillsheet(ws, incomeDic, "currency")
|
|
@@ -2766,7 +2799,7 @@ class Plan(object):
|
|
|
2766
2799
|
"all deposits": -np.sum(self.d_in, axis=0),
|
|
2767
2800
|
"ord taxes": -self.T_n,
|
|
2768
2801
|
"div taxes": -self.U_n,
|
|
2769
|
-
"Medicare": -self.
|
|
2802
|
+
"Medicare": -self.M_n,
|
|
2770
2803
|
}
|
|
2771
2804
|
sname = "Cash Flow"
|
|
2772
2805
|
ws = wb.create_sheet(sname)
|
|
@@ -3062,13 +3095,3 @@ def _formatSpreadsheet(ws, ftype):
|
|
|
3062
3095
|
cell.number_format = fstring
|
|
3063
3096
|
|
|
3064
3097
|
return None
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
def _streamPrinter(text):
|
|
3068
|
-
"""
|
|
3069
|
-
Define a stream printer to grab output from MOSEK.
|
|
3070
|
-
"""
|
|
3071
|
-
import sys
|
|
3072
|
-
|
|
3073
|
-
sys.stdout.write(text)
|
|
3074
|
-
sys.stdout.flush()
|
owlplanner/tax2025.py
CHANGED
|
@@ -42,15 +42,14 @@ taxBrackets_TCJA = np.array(
|
|
|
42
42
|
|
|
43
43
|
irmaaBrackets = np.array(
|
|
44
44
|
[
|
|
45
|
-
[0, 106000, 133000, 167000, 200000, 500000
|
|
46
|
-
[0, 212000, 266000, 334000, 400000, 750000
|
|
45
|
+
[0, 106000, 133000, 167000, 200000, 500000],
|
|
46
|
+
[0, 212000, 266000, 334000, 400000, 750000],
|
|
47
47
|
]
|
|
48
48
|
)
|
|
49
49
|
|
|
50
50
|
# Index [0] stores the standard Medicare part B premium.
|
|
51
51
|
# Following values are incremental IRMAA part B monthly fees.
|
|
52
52
|
irmaaFees = 12 * np.array([185.00, 74.00, 111.00, 110.90, 111.00, 37.00])
|
|
53
|
-
irmaaCosts = np.cumsum(irmaaFees)
|
|
54
53
|
|
|
55
54
|
# Make projection for non-TCJA using 2017 to current year.
|
|
56
55
|
# taxBrackets_2017 = np.array(
|
|
@@ -83,34 +82,6 @@ extra65Deduction = np.array([2000, 1600])
|
|
|
83
82
|
###############################################################################
|
|
84
83
|
|
|
85
84
|
|
|
86
|
-
def mediVals(yobs, horizons, gamma_n, Nn, Nq):
|
|
87
|
-
"""
|
|
88
|
-
Return tuple (nm, L, C) of year index when Medicare starts and vectors L, and C
|
|
89
|
-
defining end points of constant piecewise linear functions representing IRMAA fees.
|
|
90
|
-
"""
|
|
91
|
-
thisyear = date.today().year
|
|
92
|
-
assert Nq == len(irmaaCosts), f"Inconsistent value of Nq: {Nq}."
|
|
93
|
-
assert Nq+1 == len(irmaaBrackets[0]), "Inconsistent IRMAA brackets array."
|
|
94
|
-
Ni = len(yobs)
|
|
95
|
-
L = np.zeros((Nn, Nq+1))
|
|
96
|
-
C = np.zeros((Nn, Nq))
|
|
97
|
-
nm = 0
|
|
98
|
-
for n in range(Nn):
|
|
99
|
-
icount = 0
|
|
100
|
-
if thisyear + n - yobs[0] >= 65 and n < horizons[0]:
|
|
101
|
-
icount += 1
|
|
102
|
-
if Ni == 2 and thisyear + n - yobs[1] >= 65 and n < horizons[1]:
|
|
103
|
-
icount += 1
|
|
104
|
-
if icount > 0:
|
|
105
|
-
status = 0 if Ni == 1 else 1 if n < horizons[0] and n < horizons[1] else 0
|
|
106
|
-
L[n] = gamma_n[n] * irmaaBrackets[status]
|
|
107
|
-
C[n] = icount * gamma_n[n] * irmaaCosts
|
|
108
|
-
else:
|
|
109
|
-
nm = n + 1
|
|
110
|
-
|
|
111
|
-
return nm, L, C
|
|
112
|
-
|
|
113
|
-
|
|
114
85
|
def mediCosts(yobs, horizons, magi, prevmagi, gamma_n, Nn):
|
|
115
86
|
"""
|
|
116
87
|
Compute Medicare costs directly.
|
owlplanner/version.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "2025.
|
|
1
|
+
__version__ = "2025.05.01"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: owlplanner
|
|
3
|
-
Version: 2025.
|
|
3
|
+
Version: 2025.5.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
|
|
@@ -719,8 +719,7 @@ or fixed rates either derived from historical averages, or set by the user.
|
|
|
719
719
|
|
|
720
720
|
There are a few ways to run Owl:
|
|
721
721
|
|
|
722
|
-
- Run Owl directly on the Streamlit Community Server at
|
|
723
|
-
[owlplanner.streamlit.app](https://owlplanner.streamlit.app).
|
|
722
|
+
- Run Owl directly on the Streamlit Community Server at [owlplanner.streamlit.app](https://owlplanner.streamlit.app).
|
|
724
723
|
|
|
725
724
|
- Run locally on your computer using a Docker image.
|
|
726
725
|
Follow these [instructions](docker/README.md) for this option.
|
|
@@ -759,8 +758,8 @@ while providing a codebase where they can learn and contribute. There are and we
|
|
|
759
758
|
good retirement optimizers in the recent past, but the vast majority of them are either proprietary platforms
|
|
760
759
|
collecting your data, or academic papers that share the results without really sharing the details of
|
|
761
760
|
the underlying mathematical models.
|
|
762
|
-
The algorithms in Owl rely on the open-source HiGHS linear programming solver.
|
|
763
|
-
|
|
761
|
+
The algorithms in Owl rely on the open-source HiGHS linear programming solver. The complete formulation and
|
|
762
|
+
detailed description of the underlying
|
|
764
763
|
mathematical model can be found [here](https://raw.github.com/mdlacasse/Owl/main/docs/owl.pdf).
|
|
765
764
|
|
|
766
765
|
It is anticipated that most end users will use Owl through the graphical interface
|
|
@@ -773,18 +772,16 @@ Not every retirement decision strategy can be framed as an easy-to-solve optimiz
|
|
|
773
772
|
In particular, if one is interested in comparing different withdrawal strategies,
|
|
774
773
|
[FI Calc](ficalc.app) is an elegant application that addresses this need.
|
|
775
774
|
If, however, you also want to optimize spending, bequest, and Roth conversions, with
|
|
776
|
-
an approach also considering Medicare
|
|
775
|
+
an approach also considering Medicare and federal income tax over the next few years,
|
|
777
776
|
then Owl is definitely a tool that can help guide your decisions.
|
|
778
777
|
|
|
779
778
|
--------------------------------------------------------------------------------------
|
|
780
779
|
## Capabilities
|
|
781
|
-
Owl can optimize for either
|
|
782
|
-
of a given bequest (which can be zero),
|
|
780
|
+
Owl can optimize for either maximum net spending under the constraint of a given bequest (which can be zero),
|
|
783
781
|
or maximize the after-tax value of a bequest under the constraint of a desired net spending profile,
|
|
784
|
-
under the assumption of a heirs marginal tax rate.
|
|
785
|
-
Roth conversions are also considered, subject to an optional maximum
|
|
786
|
-
and optimized to suit the goals of the selected objective function
|
|
787
|
-
Medicare costs, and federal income tax under different rates of return assumptions.
|
|
782
|
+
and under the assumption of a heirs marginal tax rate.
|
|
783
|
+
Roth conversions are also considered, subject to an optional maximum conversion amount,
|
|
784
|
+
and optimized to suit the goals of the selected objective function.
|
|
788
785
|
All calculations are indexed for inflation, which is either provided as a fixed rate,
|
|
789
786
|
or through historical values, as are all other rates used for the calculations.
|
|
790
787
|
These rates can be used for backtesting different scenarios by choosing
|
|
@@ -842,29 +839,29 @@ which are all tracked separately for married individuals. Asset transition to th
|
|
|
842
839
|
is done according to beneficiary fractions for each type of savings account.
|
|
843
840
|
Tax status covers married filing jointly and single, depending on the number of individuals reported.
|
|
844
841
|
|
|
845
|
-
|
|
842
|
+
Medicare and IRMAA calculations are performed through a self-consistent loop on cash flow constraints.
|
|
846
843
|
Future values are simple projections of current values with the assumed inflation rates.
|
|
847
844
|
|
|
848
845
|
### Limitations
|
|
849
846
|
Owl is work in progress. At the current time:
|
|
850
847
|
- Only the US federal income tax is considered (and minimized through the optimization algorithm).
|
|
851
848
|
Head of household filing status has not been added but can easily be.
|
|
852
|
-
- Required minimum distributions are
|
|
853
|
-
but tables for spouses more than 10 years apart are not included.
|
|
849
|
+
- Required minimum distributions are calculated, but tables for spouses more than 10 years apart are not included.
|
|
854
850
|
These cases are detected and will generate an error message.
|
|
855
851
|
- Social security rule for surviving spouse assumes that benefits were taken at full retirement age.
|
|
852
|
+
- Current version has no optimization of asset allocations between individuals and/or types of savings accounts.
|
|
853
|
+
If there is interest, that could be added in the future.
|
|
856
854
|
- In the current implementation, social securiy is always taxed at 85%.
|
|
857
|
-
- Medicare calculations
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
855
|
+
- Medicare calculations are done through a self-consistent loop.
|
|
856
|
+
This means that the Medicare premiums are calculated after an initial solution is generated,
|
|
857
|
+
and then a new solution is re-generated with these premiums as a constraint.
|
|
858
|
+
In some situations, when the income (MAGI) is near an IRMAA bracket, oscillatory solutions can arise.
|
|
859
|
+
While the solutions generated are very close to one another, Owl will pick the smallest solution
|
|
860
|
+
for being conservative.
|
|
862
861
|
- Part D is not included in the IRMAA calculations. Being considerably more significant,
|
|
863
862
|
only Part B is taken into account.
|
|
864
|
-
- Future tax brackets are pure speculations derived from the little we know now and projected
|
|
865
|
-
|
|
866
|
-
- Current version has no optimization of asset allocations between individuals and/or types of savings accounts.
|
|
867
|
-
If there is interest, that could be added in the future.
|
|
863
|
+
- Future tax brackets are pure speculations derived from the little we know now and projected to the next 30 years.
|
|
864
|
+
Your guesses are as good as mine.
|
|
868
865
|
|
|
869
866
|
The solution from an optimization algorithm has only two states: feasible and infeasible.
|
|
870
867
|
Therefore, unlike event-driven simulators that can tell you that your distribution strategy runs
|
|
@@ -877,8 +874,7 @@ assets to support, even with no estate being left.
|
|
|
877
874
|
---------------------------------------------------------------
|
|
878
875
|
## Documentation
|
|
879
876
|
|
|
880
|
-
- Documentation for the app user interface is available from the interface
|
|
881
|
-
[itself](https://owlplanner.streamlit.app/Documentation).
|
|
877
|
+
- Documentation for the app user interface is available from the interface [itself](https://owlplanner.streamlit.app/Documentation).
|
|
882
878
|
- Installation guide and software requirements can be found [here](INSTALL.md).
|
|
883
879
|
- User guide for the underlying Python package as used in a Jupyter notebook can be found [here](USER_GUIDE.md).
|
|
884
880
|
|
|
@@ -895,7 +891,7 @@ assets to support, even with no estate being left.
|
|
|
895
891
|
|
|
896
892
|
---------------------------------------------------------------------
|
|
897
893
|
|
|
898
|
-
Copyright ©
|
|
894
|
+
Copyright © 2024 - Martin-D. Lacasse
|
|
899
895
|
|
|
900
896
|
Disclaimers: I am not a financial planner. You make your own decisions.
|
|
901
897
|
This program comes with no guarantee. Use at your own risk.
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
owlplanner/__init__.py,sha256=QqrdT0Qks20osBTg7h0vJHAxpP9lL7DA99xb0nYbtw4,254
|
|
2
|
-
owlplanner/abcapi.py,sha256=
|
|
2
|
+
owlplanner/abcapi.py,sha256=Lt8OUgbrfOPzAw0HyxyT2wT-IXI3d9Zo26MwyqdX56Y,6617
|
|
3
3
|
owlplanner/config.py,sha256=F6GS3n02VeFX0GCVeM4J7Ra0in4N632W6TZIXk7Yj2w,12519
|
|
4
4
|
owlplanner/logging.py,sha256=tYMw04O-XYSzjTj36fmKJGLcE1VkK6k6oJNeqtKXzuc,2530
|
|
5
|
-
owlplanner/plan.py,sha256=
|
|
5
|
+
owlplanner/plan.py,sha256=yfJRP41ExY5cqKDfBXFox9M0a3O7GeYRy4fk9EPTduk,118954
|
|
6
6
|
owlplanner/progress.py,sha256=8jlCvvtgDI89zXVNMBg1-lnEyhpPvKQS2X5oAIpoOVQ,384
|
|
7
7
|
owlplanner/rates.py,sha256=gJaoe-gJqWCQV5qVLlHp-Yn9TSJs-PJzeTbOwMCbqWs,15682
|
|
8
|
-
owlplanner/tax2025.py,sha256=
|
|
8
|
+
owlplanner/tax2025.py,sha256=JDBtFFAf2bWtKUMuE3W5F0nBhYaKBjmdJj0iayM2iGA,7829
|
|
9
9
|
owlplanner/timelists.py,sha256=tYieZU67FT6TCcQQis36JaXGI7dT6NqD7RvdEjgJL4M,4026
|
|
10
10
|
owlplanner/utils.py,sha256=WpJgn79YZfH8UCkcmhd-AZlxlGuz1i1-UDBRXImsY6I,2485
|
|
11
|
-
owlplanner/version.py,sha256=
|
|
11
|
+
owlplanner/version.py,sha256=i0t7xBSM4RiKs64A-MRBD5qug-N0BpH70YpBYsc7Lbw,28
|
|
12
12
|
owlplanner/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
13
|
owlplanner/data/rates.csv,sha256=6fxg56BVVORrj9wJlUGFdGXKvOX5r7CSca8uhUbbuIU,3734
|
|
14
|
-
owlplanner-2025.
|
|
15
|
-
owlplanner-2025.
|
|
16
|
-
owlplanner-2025.
|
|
17
|
-
owlplanner-2025.
|
|
14
|
+
owlplanner-2025.5.1.dist-info/METADATA,sha256=wfo48m-vul82qOlWxk3X6lrbabChuLTxNBJktKCgse0,53926
|
|
15
|
+
owlplanner-2025.5.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
16
|
+
owlplanner-2025.5.1.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
|
|
17
|
+
owlplanner-2025.5.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|