owlplanner 2025.4.26__py3-none-any.whl → 2025.4.28__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 +2 -0
- owlplanner/plan.py +267 -271
- owlplanner/tax2025.py +31 -2
- owlplanner/version.py +1 -1
- {owlplanner-2025.4.26.dist-info → owlplanner-2025.4.28.dist-info}/METADATA +27 -23
- {owlplanner-2025.4.26.dist-info → owlplanner-2025.4.28.dist-info}/RECORD +8 -8
- {owlplanner-2025.4.26.dist-info → owlplanner-2025.4.28.dist-info}/WHEEL +0 -0
- {owlplanner-2025.4.26.dist-info → owlplanner-2025.4.28.dist-info}/licenses/LICENSE +0 -0
owlplanner/abcapi.py
CHANGED
owlplanner/plan.py
CHANGED
|
@@ -230,11 +230,12 @@ class Plan(object):
|
|
|
230
230
|
self._name = name
|
|
231
231
|
self.setLogstreams(verbose, logstreams)
|
|
232
232
|
|
|
233
|
-
# 7 tax brackets, 3 types of accounts, 4 classes of assets.
|
|
233
|
+
# 7 tax brackets, 6 Medicare levels, 3 types of accounts, 4 classes of assets.
|
|
234
234
|
self.N_t = 7
|
|
235
|
+
self.N_q = 6
|
|
235
236
|
self.N_j = 3
|
|
236
237
|
self.N_k = 4
|
|
237
|
-
# 2 binary variables.
|
|
238
|
+
# 2 binary variables per year per invididual.
|
|
238
239
|
self.N_z = 2
|
|
239
240
|
|
|
240
241
|
# Default interpolation parameters for allocation ratios.
|
|
@@ -304,8 +305,8 @@ class Plan(object):
|
|
|
304
305
|
self.myRothX_in = np.zeros((self.N_i, self.N_n))
|
|
305
306
|
self.kappa_ijn = np.zeros((self.N_i, self.N_j, self.N_n))
|
|
306
307
|
|
|
307
|
-
# Previous
|
|
308
|
-
self.
|
|
308
|
+
# Previous 2 years and current year for Medicare.
|
|
309
|
+
self.prevMAGIs = np.zeros((3))
|
|
309
310
|
|
|
310
311
|
# Default slack on profile.
|
|
311
312
|
self.lambdha = 0
|
|
@@ -323,7 +324,7 @@ class Plan(object):
|
|
|
323
324
|
# If none was given, default is to begin plan on today's date.
|
|
324
325
|
self._setStartingDate(startDate)
|
|
325
326
|
|
|
326
|
-
self._buildOffsetMap()
|
|
327
|
+
# self._buildOffsetMap()
|
|
327
328
|
|
|
328
329
|
# Initialize guardrails to ensure proper configuration.
|
|
329
330
|
self._adjustedParameters = False
|
|
@@ -1021,23 +1022,27 @@ class Plan(object):
|
|
|
1021
1022
|
|
|
1022
1023
|
return None
|
|
1023
1024
|
|
|
1024
|
-
def _buildOffsetMap(self):
|
|
1025
|
+
def _buildOffsetMap(self, options):
|
|
1025
1026
|
"""
|
|
1026
1027
|
Utility function to map variables to a block vector.
|
|
1027
1028
|
Refer to companion document for explanations.
|
|
1028
1029
|
"""
|
|
1029
|
-
|
|
1030
|
+
medi = options.get("withMedicare", True)
|
|
1031
|
+
|
|
1032
|
+
# Stack all variables in a single block vector with offsets saved in a dictionary.
|
|
1030
1033
|
C = {}
|
|
1031
1034
|
C["b"] = 0
|
|
1032
1035
|
C["d"] = _qC(C["b"], self.N_i, self.N_j, self.N_n + 1)
|
|
1033
1036
|
C["e"] = _qC(C["d"], self.N_i, self.N_n)
|
|
1034
1037
|
C["F"] = _qC(C["e"], self.N_n)
|
|
1035
1038
|
C["g"] = _qC(C["F"], self.N_t, self.N_n)
|
|
1036
|
-
C["
|
|
1039
|
+
C["m"] = _qC(C["g"], self.N_n)
|
|
1040
|
+
C["s"] = _qC(C["m"], self.N_n) if medi else C["m"]
|
|
1037
1041
|
C["w"] = _qC(C["s"], self.N_n)
|
|
1038
1042
|
C["x"] = _qC(C["w"], self.N_i, self.N_j, self.N_n)
|
|
1039
|
-
C["
|
|
1040
|
-
|
|
1043
|
+
C["zx"] = _qC(C["x"], self.N_i, self.N_n)
|
|
1044
|
+
C["zm"] = _qC(C["zx"], self.N_i, self.N_n, self.N_z)
|
|
1045
|
+
self.nvars = _qC(C["zm"], self.N_n, self.N_q) if medi else C["zm"]
|
|
1041
1046
|
|
|
1042
1047
|
self.C = C
|
|
1043
1048
|
self.mylog.vprint(f"Problem has {len(C)} distinct time series forming {self.nvars} decision variables.")
|
|
@@ -1057,6 +1062,7 @@ class Plan(object):
|
|
|
1057
1062
|
Ni = self.N_i
|
|
1058
1063
|
Nj = self.N_j
|
|
1059
1064
|
Nk = self.N_k
|
|
1065
|
+
Nq = self.N_q
|
|
1060
1066
|
Nn = self.N_n
|
|
1061
1067
|
Nt = self.N_t
|
|
1062
1068
|
Nz = self.N_z
|
|
@@ -1064,15 +1070,19 @@ class Plan(object):
|
|
|
1064
1070
|
i_s = self.i_s
|
|
1065
1071
|
n_d = self.n_d
|
|
1066
1072
|
|
|
1073
|
+
self._buildOffsetMap(options)
|
|
1074
|
+
|
|
1067
1075
|
Cb = self.C["b"]
|
|
1068
1076
|
Cd = self.C["d"]
|
|
1069
1077
|
Ce = self.C["e"]
|
|
1070
1078
|
CF = self.C["F"]
|
|
1071
1079
|
Cg = self.C["g"]
|
|
1080
|
+
Cm = self.C["m"]
|
|
1072
1081
|
Cs = self.C["s"]
|
|
1073
1082
|
Cw = self.C["w"]
|
|
1074
1083
|
Cx = self.C["x"]
|
|
1075
|
-
|
|
1084
|
+
Czx = self.C["zx"]
|
|
1085
|
+
Czm = self.C["zm"]
|
|
1076
1086
|
|
|
1077
1087
|
spLo = 1 - self.lambdha
|
|
1078
1088
|
spHi = 1 + self.lambdha
|
|
@@ -1097,17 +1107,44 @@ class Plan(object):
|
|
|
1097
1107
|
A = abc.ConstraintMatrix(self.nvars)
|
|
1098
1108
|
B = abc.Bounds(self.nvars)
|
|
1099
1109
|
|
|
1100
|
-
#
|
|
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]
|
|
1117
|
+
|
|
1118
|
+
row = A.newRow()
|
|
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...
|
|
1101
1139
|
for i in range(Ni):
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
A.addNewRow(rowDic, zero, inf)
|
|
1140
|
+
for n in range(self.horizons[i]):
|
|
1141
|
+
rowDic = {
|
|
1142
|
+
_q3(Cw, i, 1, n, Ni, Nj, Nn): 1,
|
|
1143
|
+
_q3(Cb, i, 1, n, Ni, Nj, Nn + 1): -self.rho_in[i, n],
|
|
1144
|
+
}
|
|
1145
|
+
A.addNewRow(rowDic, zero, inf)
|
|
1109
1146
|
|
|
1110
|
-
# Income tax bracket range inequalities.
|
|
1147
|
+
# Income tax bracket range inequalities, from 0 to upper bound.
|
|
1111
1148
|
for t in range(Nt):
|
|
1112
1149
|
for n in range(Nn):
|
|
1113
1150
|
B.set0_Ub(_q2(CF, t, n, Nt, Nn), self.DeltaBar_tn[t, n])
|
|
@@ -1116,8 +1153,31 @@ class Plan(object):
|
|
|
1116
1153
|
for n in range(Nn):
|
|
1117
1154
|
B.set0_Ub(_q1(Ce, n, Nn), self.sigmaBar_n[n])
|
|
1118
1155
|
|
|
1119
|
-
#
|
|
1120
|
-
|
|
1156
|
+
# Impose withdrawal limits on all accounts.
|
|
1157
|
+
for i in range(Ni):
|
|
1158
|
+
for j in range(Nj):
|
|
1159
|
+
for n in range(Nn):
|
|
1160
|
+
rowDic = {_q3(Cw, i, j, n, Ni, Nj, Nn): -1,
|
|
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)
|
|
1176
|
+
for j in range(Nj):
|
|
1177
|
+
B.set0_Ub(_q3(Cw, i_d, j, n, Ni, Nj, Nn), zero)
|
|
1178
|
+
|
|
1179
|
+
# Roth conversions bounds, equalities, and inequalities.
|
|
1180
|
+
# Condition "file" supercedes everything else.
|
|
1121
1181
|
if "maxRothConversion" in options and options["maxRothConversion"] == "file":
|
|
1122
1182
|
# self.mylog.vprint(f"Fixing Roth conversions to those from file {self.timeListsFileName}.")
|
|
1123
1183
|
for i in range(Ni):
|
|
@@ -1162,59 +1222,13 @@ class Plan(object):
|
|
|
1162
1222
|
for n in range(Nn):
|
|
1163
1223
|
B.set0_Ub(_q2(Cx, i_x, n, Ni, Nn), zero)
|
|
1164
1224
|
|
|
1165
|
-
# Impose withdrawal limits on taxable and tax-exempt accounts.
|
|
1166
|
-
for i in range(Ni):
|
|
1167
|
-
for j in [0, 2]:
|
|
1168
|
-
for n in range(Nn):
|
|
1169
|
-
rowDic = {_q3(Cw, i, j, n, Ni, Nj, Nn): -1, _q3(Cb, i, j, n, Ni, Nj, Nn + 1): 1}
|
|
1170
|
-
A.addNewRow(rowDic, zero, inf)
|
|
1171
|
-
|
|
1172
|
-
# Impose withdrawals and conversion limits on tax-deferred account.
|
|
1173
|
-
for i in range(Ni):
|
|
1174
|
-
for n in range(Nn):
|
|
1175
|
-
rowDic = {
|
|
1176
|
-
_q2(Cx, i, n, Ni, Nn): -1,
|
|
1177
|
-
_q3(Cw, i, 1, n, Ni, Nj, Nn): -1,
|
|
1178
|
-
_q3(Cb, i, 1, n, Ni, Nj, Nn + 1): 1,
|
|
1179
|
-
}
|
|
1180
|
-
A.addNewRow(rowDic, zero, inf)
|
|
1181
|
-
|
|
1182
|
-
# Constraints depending on objective function.
|
|
1183
|
-
if objective == "maxSpending":
|
|
1184
|
-
# Impose optional constraint on final bequest requested in today's $.
|
|
1185
|
-
if "bequest" in options:
|
|
1186
|
-
bequest = options["bequest"]
|
|
1187
|
-
assert isinstance(bequest, (int, float)), "Desired bequest is not a number."
|
|
1188
|
-
bequest *= units * self.gamma_n[-1]
|
|
1189
|
-
else:
|
|
1190
|
-
# If not specified, defaults to $1 (nominal $).
|
|
1191
|
-
bequest = 1
|
|
1192
|
-
|
|
1193
|
-
row = A.newRow()
|
|
1194
|
-
for i in range(Ni):
|
|
1195
|
-
row.addElem(_q3(Cb, i, 0, Nn, Ni, Nj, Nn + 1), 1)
|
|
1196
|
-
row.addElem(_q3(Cb, i, 1, Nn, Ni, Nj, Nn + 1), 1 - self.nu)
|
|
1197
|
-
# Nudge could be added (e.g. 1.02) to artificially favor tax-exempt account
|
|
1198
|
-
# as heirs's benefits of 10y tax-free is not weighted in?
|
|
1199
|
-
row.addElem(_q3(Cb, i, 2, Nn, Ni, Nj, Nn + 1), 1)
|
|
1200
|
-
A.addRow(row, bequest, bequest)
|
|
1201
|
-
# self.mylog.vprint('Adding bequest constraint of:', u.d(bequest))
|
|
1202
|
-
elif objective == "maxBequest":
|
|
1203
|
-
spending = options["netSpending"]
|
|
1204
|
-
assert isinstance(spending, (int, float)), "Desired spending provided is not a number."
|
|
1205
|
-
# Account for time elapsed in the current year.
|
|
1206
|
-
spending *= units * self.yearFracLeft
|
|
1207
|
-
# self.mylog.vprint('Maximizing bequest with desired net spending of:', u.d(spending))
|
|
1208
|
-
# To allow slack in first year, Cg can be made Nn+1 and store basis in g[Nn].
|
|
1209
|
-
A.addNewRow({_q1(Cg, 0, Nn): 1}, spending, spending)
|
|
1210
|
-
|
|
1211
1225
|
# Set initial balances through constraints.
|
|
1212
1226
|
for i in range(Ni):
|
|
1213
1227
|
for j in range(Nj):
|
|
1214
1228
|
rhs = self.beta_ij[i, j]
|
|
1215
1229
|
A.addNewRow({_q3(Cb, i, j, 0, Ni, Nj, Nn + 1): 1}, rhs, rhs)
|
|
1216
1230
|
|
|
1217
|
-
# Link surplus and taxable account deposits
|
|
1231
|
+
# Link cash flow surplus and taxable account deposits even for Ni=1.
|
|
1218
1232
|
for i in range(Ni):
|
|
1219
1233
|
fac1 = u.krond(i, 0) * (1 - self.eta) + u.krond(i, 1) * self.eta
|
|
1220
1234
|
for n in range(n_d):
|
|
@@ -1228,63 +1242,51 @@ class Plan(object):
|
|
|
1228
1242
|
# No surplus allowed during the last year to be used as a tax loophole.
|
|
1229
1243
|
B.set0_Ub(_q1(Cs, Nn - 1, Nn), zero)
|
|
1230
1244
|
|
|
1231
|
-
if Ni == 2:
|
|
1232
|
-
# No conversion during last year.
|
|
1233
|
-
# B.set0_Ub(_q2(Cx, i_d, nd-1, Ni, Nn), zero)
|
|
1234
|
-
# B.set0_Ub(_q2(Cx, i_s, Nn-1, Ni, Nn), zero)
|
|
1235
|
-
|
|
1236
|
-
# No withdrawals or deposits for any i_d-owned accounts after year of passing.
|
|
1237
|
-
# Implicit n_d < Nn imposed by for loop.
|
|
1238
|
-
for n in range(n_d, Nn):
|
|
1239
|
-
B.set0_Ub(_q2(Cd, i_d, n, Ni, Nn), zero)
|
|
1240
|
-
B.set0_Ub(_q2(Cx, i_d, n, Ni, Nn), zero)
|
|
1241
|
-
for j in range(Nj):
|
|
1242
|
-
B.set0_Ub(_q3(Cw, i_d, j, n, Ni, Nj, Nn), zero)
|
|
1243
|
-
|
|
1244
1245
|
# Account balances carried from year to year.
|
|
1245
1246
|
# Considering spousal asset transfer at passing of a spouse.
|
|
1246
1247
|
# Using hybrid approach with 'if' statement and Kronecker deltas.
|
|
1247
1248
|
for i in range(Ni):
|
|
1248
1249
|
for j in range(Nj):
|
|
1249
1250
|
for n in range(Nn):
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
else:
|
|
1254
|
-
fac1 = 1
|
|
1255
|
-
|
|
1251
|
+
# fac1 = 1 - (u.krond(n, n_d - 1) * u.krond(i, i_d))
|
|
1252
|
+
fac1 = 0 if (Ni == 2 and n_d < Nn and i == i_d and n == n_d - 1) else 1
|
|
1253
|
+
fac1_ijn = fac1 * Tau1_ijn[i, j, n]
|
|
1256
1254
|
rhs = fac1 * self.kappa_ijn[i, j, n] * Tauh_ijn[i, j, n]
|
|
1257
1255
|
|
|
1258
1256
|
row = A.newRow()
|
|
1259
1257
|
row.addElem(_q3(Cb, i, j, n + 1, Ni, Nj, Nn + 1), 1)
|
|
1260
|
-
row.addElem(_q3(Cb, i, j, n, Ni, Nj, Nn + 1), -
|
|
1261
|
-
row.addElem(
|
|
1262
|
-
row.addElem(
|
|
1258
|
+
row.addElem(_q3(Cb, i, j, n, Ni, Nj, Nn + 1), -fac1_ijn)
|
|
1259
|
+
row.addElem(_q2(Cd, i, n, Ni, Nn), -u.krond(j, 0) * fac1_ijn)
|
|
1260
|
+
row.addElem(_q3(Cw, i, j, n, Ni, Nj, Nn), fac1_ijn)
|
|
1263
1261
|
row.addElem(
|
|
1264
1262
|
_q2(Cx, i, n, Ni, Nn),
|
|
1265
|
-
-
|
|
1263
|
+
-(u.krond(j, 2) - u.krond(j, 1)) * fac1_ijn,
|
|
1266
1264
|
)
|
|
1267
1265
|
|
|
1268
1266
|
if Ni == 2 and n_d < Nn and i == i_s and n == n_d - 1:
|
|
1269
1267
|
fac2 = self.phi_j[j]
|
|
1268
|
+
fac2_idjn = fac2 * Tau1_ijn[i_d, j, n]
|
|
1270
1269
|
rhs += fac2 * self.kappa_ijn[i_d, j, n] * Tauh_ijn[i_d, j, n]
|
|
1271
|
-
|
|
1272
|
-
row.addElem(_q3(
|
|
1273
|
-
row.addElem(_q2(Cd, i_d, n, Ni, Nn), -
|
|
1270
|
+
|
|
1271
|
+
row.addElem(_q3(Cb, i_d, j, n, Ni, Nj, Nn + 1), -fac2_idjn)
|
|
1272
|
+
row.addElem(_q2(Cd, i_d, n, Ni, Nn), -u.krond(j, 0) * fac2_idjn)
|
|
1273
|
+
row.addElem(_q3(Cw, i_d, j, n, Ni, Nj, Nn), fac2_idjn)
|
|
1274
1274
|
row.addElem(
|
|
1275
1275
|
_q2(Cx, i_d, n, Ni, Nn),
|
|
1276
|
-
-
|
|
1276
|
+
-(u.krond(j, 2) - u.krond(j, 1)) * fac2_idjn,
|
|
1277
1277
|
)
|
|
1278
1278
|
A.addRow(row, rhs, rhs)
|
|
1279
1279
|
|
|
1280
1280
|
tau_0prev = np.roll(self.tau_kn[0, :], 1)
|
|
1281
|
+
# No tax on losses, nor tax-loss harvesting.
|
|
1281
1282
|
tau_0prev[tau_0prev < 0] = 0
|
|
1282
1283
|
|
|
1283
|
-
#
|
|
1284
|
+
# Cash flow for net spending.
|
|
1284
1285
|
for n in range(Nn):
|
|
1285
|
-
rhs =
|
|
1286
|
-
row = A.newRow(
|
|
1287
|
-
row.addElem(_q1(
|
|
1286
|
+
rhs = 0
|
|
1287
|
+
row = A.newRow()
|
|
1288
|
+
row.addElem(_q1(Cg, n, Nn), 1)
|
|
1289
|
+
row.addElem(_q1(Cm, n, Nn), 1)
|
|
1288
1290
|
for i in range(Ni):
|
|
1289
1291
|
fac = self.psi * self.alpha_ijkn[i, 0, 0, n]
|
|
1290
1292
|
rhs += (
|
|
@@ -1296,13 +1298,13 @@ class Plan(object):
|
|
|
1296
1298
|
)
|
|
1297
1299
|
|
|
1298
1300
|
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)
|
|
1299
1302
|
# Minus capital gains on taxable withdrawals using last year's rate if >=0.
|
|
1300
1303
|
# Plus taxable account withdrawals, and all other withdrawals.
|
|
1301
|
-
row.addElem(_q3(Cw, i, 0, n, Ni, Nj, Nn), fac * (tau_0prev[n] - self.mu)
|
|
1304
|
+
row.addElem(_q3(Cw, i, 0, n, Ni, Nj, Nn), -1 + fac * (tau_0prev[n] - self.mu))
|
|
1302
1305
|
penalty = 0.1 if n < self.n59[i] else 0
|
|
1303
1306
|
row.addElem(_q3(Cw, i, 1, n, Ni, Nj, Nn), -1 + penalty)
|
|
1304
1307
|
row.addElem(_q3(Cw, i, 2, n, Ni, Nj, Nn), -1 + penalty)
|
|
1305
|
-
row.addElem(_q2(Cd, i, n, Ni, Nn), fac * self.mu)
|
|
1306
1308
|
|
|
1307
1309
|
# Minus tax on ordinary income, T_n.
|
|
1308
1310
|
for t in range(Nt):
|
|
@@ -1310,7 +1312,7 @@ class Plan(object):
|
|
|
1310
1312
|
|
|
1311
1313
|
A.addRow(row, rhs, rhs)
|
|
1312
1314
|
|
|
1313
|
-
#
|
|
1315
|
+
# Enforce income profile.
|
|
1314
1316
|
for n in range(1, Nn):
|
|
1315
1317
|
rowDic = {_q1(Cg, 0, Nn): -spLo * self.xiBar_n[n], _q1(Cg, n, Nn): self.xiBar_n[0]}
|
|
1316
1318
|
A.addNewRow(rowDic, zero, inf)
|
|
@@ -1329,33 +1331,102 @@ class Plan(object):
|
|
|
1329
1331
|
row.addElem(_q2(Cx, i, n, Ni, Nn), -1)
|
|
1330
1332
|
|
|
1331
1333
|
# Taxable returns on securities in taxable account.
|
|
1332
|
-
|
|
1333
|
-
rhs += 0.5 *
|
|
1334
|
-
row.addElem(_q3(Cb, i, 0, n, Ni, Nj, Nn + 1), -
|
|
1335
|
-
row.addElem(_q3(Cw, i, 0, n, Ni, Nj, Nn),
|
|
1336
|
-
row.addElem(_q2(Cd, i, n, Ni, Nn), -
|
|
1334
|
+
fak_i = np.sum(self.tau_kn[1:Nk, n] * self.alpha_ijkn[i, 0, 1:Nk, n], axis=0)
|
|
1335
|
+
rhs += 0.5 * fak_i * self.kappa_ijn[i, 0, n]
|
|
1336
|
+
row.addElem(_q3(Cb, i, 0, n, Ni, Nj, Nn + 1), -fak_i)
|
|
1337
|
+
row.addElem(_q3(Cw, i, 0, n, Ni, Nj, Nn), fak_i)
|
|
1338
|
+
row.addElem(_q2(Cd, i, n, Ni, Nn), -fak_i)
|
|
1337
1339
|
|
|
1338
1340
|
for t in range(Nt):
|
|
1339
1341
|
row.addElem(_q2(CF, t, n, Nt, Nn), 1)
|
|
1340
1342
|
|
|
1341
1343
|
A.addRow(row, rhs, rhs)
|
|
1342
1344
|
|
|
1343
|
-
#
|
|
1345
|
+
# Medicare calculations.
|
|
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.
|
|
1344
1414
|
for i in range(Ni):
|
|
1345
1415
|
for n in range(self.horizons[i]):
|
|
1416
|
+
# Configure binary variables.
|
|
1346
1417
|
for z in range(Nz):
|
|
1347
|
-
B.setBinary(_q3(
|
|
1418
|
+
B.setBinary(_q3(Czx, i, n, z, Ni, Nn, Nz))
|
|
1348
1419
|
|
|
1349
1420
|
# Exclude simultaneous deposits and withdrawals from taxable or tax-free accounts.
|
|
1350
1421
|
A.addNewRow(
|
|
1351
|
-
{_q3(
|
|
1422
|
+
{_q3(Czx, i, n, 0, Ni, Nn, Nz): bigM, _q1(Cs, n, Nn): -1},
|
|
1352
1423
|
zero,
|
|
1353
1424
|
bigM,
|
|
1354
1425
|
)
|
|
1355
1426
|
|
|
1356
1427
|
A.addNewRow(
|
|
1357
1428
|
{
|
|
1358
|
-
_q3(
|
|
1429
|
+
_q3(Czx, i, n, 0, Ni, Nn, Nz): bigM,
|
|
1359
1430
|
_q3(Cw, i, 0, n, Ni, Nj, Nn): 1,
|
|
1360
1431
|
_q3(Cw, i, 2, n, Ni, Nj, Nn): 1,
|
|
1361
1432
|
},
|
|
@@ -1365,13 +1436,13 @@ class Plan(object):
|
|
|
1365
1436
|
|
|
1366
1437
|
# Exclude simultaneous Roth conversions and tax-exempt withdrawals.
|
|
1367
1438
|
A.addNewRow(
|
|
1368
|
-
{_q3(
|
|
1439
|
+
{_q3(Czx, i, n, 1, Ni, Nn, Nz): bigM, _q2(Cx, i, n, Ni, Nn): -1},
|
|
1369
1440
|
zero,
|
|
1370
1441
|
bigM,
|
|
1371
1442
|
)
|
|
1372
1443
|
|
|
1373
1444
|
A.addNewRow(
|
|
1374
|
-
{_q3(
|
|
1445
|
+
{_q3(Czx, i, n, 1, Ni, Nn, Nz): bigM, _q3(Cw, i, 2, n, Ni, Nj, Nn): 1},
|
|
1375
1446
|
zero,
|
|
1376
1447
|
bigM,
|
|
1377
1448
|
)
|
|
@@ -1663,14 +1734,14 @@ class Plan(object):
|
|
|
1663
1734
|
if objective == "maxSpending" and "bequest" not in myoptions:
|
|
1664
1735
|
self.mylog.vprint("Using bequest of $1.")
|
|
1665
1736
|
|
|
1666
|
-
self.
|
|
1737
|
+
self.prevMAGIs = np.zeros(3)
|
|
1667
1738
|
if "previousMAGIs" in myoptions:
|
|
1668
1739
|
magi = myoptions["previousMAGIs"]
|
|
1669
1740
|
if len(magi) != 3:
|
|
1670
1741
|
raise ValueError("previousMAGIs must have 3 values.")
|
|
1671
1742
|
|
|
1672
1743
|
units = u.getUnits(options.get("units", "k"))
|
|
1673
|
-
self.
|
|
1744
|
+
self.prevMAGIs = units * np.array(magi)
|
|
1674
1745
|
|
|
1675
1746
|
self.lambdha = 0
|
|
1676
1747
|
if "spendingSlack" in myoptions:
|
|
@@ -1704,76 +1775,31 @@ class Plan(object):
|
|
|
1704
1775
|
"""
|
|
1705
1776
|
from scipy import optimize
|
|
1706
1777
|
|
|
1707
|
-
withMedicare = True
|
|
1708
|
-
if "withMedicare" in options and options["withMedicare"] is False:
|
|
1709
|
-
withMedicare = False
|
|
1710
|
-
|
|
1711
|
-
if objective == "maxSpending":
|
|
1712
|
-
objFac = -1 / self.xi_n[0]
|
|
1713
|
-
else:
|
|
1714
|
-
objFac = -1 / self.gamma_n[-1]
|
|
1715
|
-
|
|
1716
1778
|
# mip_rel_gap smaller than 1e-6 can lead to oscillatory solutions.
|
|
1717
1779
|
milpOptions = {"disp": False, "mip_rel_gap": 1e-7}
|
|
1718
1780
|
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
self.
|
|
1724
|
-
while True:
|
|
1725
|
-
self._buildConstraints(objective, options)
|
|
1726
|
-
Alu, lbvec, ubvec = self.A.arrays()
|
|
1727
|
-
Lb, Ub = self.B.arrays()
|
|
1728
|
-
integrality = self.B.integralityArray()
|
|
1729
|
-
c = self.c.arrays()
|
|
1730
|
-
|
|
1731
|
-
bounds = optimize.Bounds(Lb, Ub)
|
|
1732
|
-
constraint = optimize.LinearConstraint(Alu, lbvec, ubvec)
|
|
1733
|
-
solution = optimize.milp(
|
|
1734
|
-
c,
|
|
1735
|
-
integrality=integrality,
|
|
1736
|
-
constraints=constraint,
|
|
1737
|
-
bounds=bounds,
|
|
1738
|
-
options=milpOptions,
|
|
1739
|
-
)
|
|
1740
|
-
it += 1
|
|
1741
|
-
|
|
1742
|
-
if not solution.success:
|
|
1743
|
-
break
|
|
1744
|
-
|
|
1745
|
-
if not withMedicare:
|
|
1746
|
-
break
|
|
1747
|
-
|
|
1748
|
-
self._estimateMedicare(solution.x)
|
|
1781
|
+
self._buildConstraints(objective, options)
|
|
1782
|
+
Alu, lbvec, ubvec = self.A.arrays()
|
|
1783
|
+
Lb, Ub = self.B.arrays()
|
|
1784
|
+
integrality = self.B.integralityArray()
|
|
1785
|
+
c = self.c.arrays()
|
|
1749
1786
|
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
if absdiff < 1:
|
|
1755
|
-
self.mylog.vprint("Converged on full solution.")
|
|
1756
|
-
break
|
|
1757
|
-
|
|
1758
|
-
# Avoid oscillatory solutions. Look only at most recent solutions. Within $10.
|
|
1759
|
-
isclosenough = abs(-solution.fun - min(old_solutions[int(it / 2) :])) < 10 * self.xi_n[0]
|
|
1760
|
-
if isclosenough:
|
|
1761
|
-
self.mylog.vprint("Converged through selecting minimum oscillating objective.")
|
|
1762
|
-
break
|
|
1763
|
-
|
|
1764
|
-
if it > 59:
|
|
1765
|
-
self.mylog.vprint("WARNING: Exiting loop on maximum iterations.")
|
|
1766
|
-
break
|
|
1767
|
-
|
|
1768
|
-
old_solutions.append(-solution.fun)
|
|
1769
|
-
old_x = solution.x
|
|
1787
|
+
bounds = optimize.Bounds(Lb, Ub)
|
|
1788
|
+
constraint = optimize.LinearConstraint(Alu, lbvec, ubvec)
|
|
1789
|
+
solution = optimize.milp(c, integrality=integrality,
|
|
1790
|
+
constraints=constraint, bounds=bounds, options=milpOptions)
|
|
1770
1791
|
|
|
1771
1792
|
if solution.success:
|
|
1772
|
-
self.mylog.vprint(
|
|
1793
|
+
self.mylog.vprint("Solution successful.")
|
|
1773
1794
|
self.mylog.vprint(solution.message)
|
|
1795
|
+
if objective == "maxSpending":
|
|
1796
|
+
objFac = -1 / self.xi_n[0]
|
|
1797
|
+
else:
|
|
1798
|
+
objFac = -1 / self.gamma_n[-1]
|
|
1799
|
+
|
|
1774
1800
|
self.mylog.vprint(f"Objective: {u.d(solution.fun * objFac)}")
|
|
1775
1801
|
# self.mylog.vprint('Upper bound:', u.d(-solution.mip_dual_bound))
|
|
1776
|
-
self._aggregateResults(solution.x)
|
|
1802
|
+
self._aggregateResults(options, solution.x)
|
|
1777
1803
|
self._timestamp = datetime.now().strftime("%Y-%m-%d at %H:%M:%S")
|
|
1778
1804
|
self.caseStatus = "solved"
|
|
1779
1805
|
else:
|
|
@@ -1788,17 +1814,6 @@ class Plan(object):
|
|
|
1788
1814
|
"""
|
|
1789
1815
|
import mosek
|
|
1790
1816
|
|
|
1791
|
-
withMedicare = True
|
|
1792
|
-
if "withMedicare" in options and options["withMedicare"] is False:
|
|
1793
|
-
withMedicare = False
|
|
1794
|
-
|
|
1795
|
-
if objective == "maxSpending":
|
|
1796
|
-
objFac = -1 / self.xi_n[0]
|
|
1797
|
-
else:
|
|
1798
|
-
objFac = -1 / self.gamma_n[-1]
|
|
1799
|
-
|
|
1800
|
-
# mip_rel_gap smaller than 1e-6 can lead to oscillatory solutions.
|
|
1801
|
-
|
|
1802
1817
|
bdic = {
|
|
1803
1818
|
"fx": mosek.boundkey.fx,
|
|
1804
1819
|
"fr": mosek.boundkey.fr,
|
|
@@ -1807,88 +1822,57 @@ class Plan(object):
|
|
|
1807
1822
|
"up": mosek.boundkey.up,
|
|
1808
1823
|
}
|
|
1809
1824
|
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
self.
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
Aind, Aval, clb, cub = self.A.lists()
|
|
1818
|
-
ckeys = self.A.keys()
|
|
1819
|
-
vlb, vub = self.B.arrays()
|
|
1820
|
-
integrality = self.B.integralityList()
|
|
1821
|
-
vkeys = self.B.keys()
|
|
1822
|
-
cind, cval = self.c.lists()
|
|
1823
|
-
|
|
1824
|
-
task = mosek.Task()
|
|
1825
|
-
# task.putdouparam(mosek.dparam.mio_rel_gap_const, 1e-5)
|
|
1826
|
-
# task.putdouparam(mosek.dparam.mio_tol_abs_relax_int, 1e-4)
|
|
1827
|
-
# task.set_Stream(mosek.streamtype.msg, _streamPrinter)
|
|
1828
|
-
task.appendcons(self.A.ncons)
|
|
1829
|
-
task.appendvars(self.A.nvars)
|
|
1830
|
-
|
|
1831
|
-
for ii in range(len(cind)):
|
|
1832
|
-
task.putcj(cind[ii], cval[ii])
|
|
1833
|
-
|
|
1834
|
-
for ii in range(self.nvars):
|
|
1835
|
-
task.putvarbound(ii, bdic[vkeys[ii]], vlb[ii], vub[ii])
|
|
1836
|
-
|
|
1837
|
-
for ii in range(len(integrality)):
|
|
1838
|
-
task.putvartype(integrality[ii], mosek.variabletype.type_int)
|
|
1839
|
-
|
|
1840
|
-
for ii in range(self.A.ncons):
|
|
1841
|
-
task.putarow(ii, Aind[ii], Aval[ii])
|
|
1842
|
-
task.putconbound(ii, bdic[ckeys[ii]], clb[ii], cub[ii])
|
|
1843
|
-
|
|
1844
|
-
task.putobjsense(mosek.objsense.minimize)
|
|
1845
|
-
task.optimize()
|
|
1846
|
-
|
|
1847
|
-
solsta = task.getsolsta(mosek.soltype.itg)
|
|
1848
|
-
# prosta = task.getprosta(mosek.soltype.itg)
|
|
1849
|
-
it += 1
|
|
1850
|
-
|
|
1851
|
-
if solsta != mosek.solsta.integer_optimal:
|
|
1852
|
-
break
|
|
1825
|
+
self._buildConstraints(objective, options)
|
|
1826
|
+
Aind, Aval, clb, cub = self.A.lists()
|
|
1827
|
+
ckeys = self.A.keys()
|
|
1828
|
+
vlb, vub = self.B.arrays()
|
|
1829
|
+
integrality = self.B.integralityList()
|
|
1830
|
+
vkeys = self.B.keys()
|
|
1831
|
+
cind, cval = self.c.lists()
|
|
1853
1832
|
|
|
1854
|
-
|
|
1855
|
-
|
|
1833
|
+
task = mosek.Task()
|
|
1834
|
+
# task.putdouparam(mosek.dparam.mio_rel_gap_const, 1e-5)
|
|
1835
|
+
# task.putdouparam(mosek.dparam.mio_tol_abs_relax_int, 1e-4)
|
|
1836
|
+
# task.set_Stream(mosek.streamtype.msg, _streamPrinter)
|
|
1837
|
+
task.appendcons(self.A.ncons)
|
|
1838
|
+
task.appendvars(self.A.nvars)
|
|
1856
1839
|
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
self._estimateMedicare(xx)
|
|
1840
|
+
for ii in range(len(cind)):
|
|
1841
|
+
task.putcj(cind[ii], cval[ii])
|
|
1861
1842
|
|
|
1862
|
-
|
|
1843
|
+
for ii in range(self.nvars):
|
|
1844
|
+
task.putvarbound(ii, bdic[vkeys[ii]], vlb[ii], vub[ii])
|
|
1863
1845
|
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
if absdiff < 1:
|
|
1867
|
-
self.mylog.vprint("Converged on full solution.")
|
|
1868
|
-
break
|
|
1846
|
+
for ii in range(len(integrality)):
|
|
1847
|
+
task.putvartype(integrality[ii], mosek.variabletype.type_int)
|
|
1869
1848
|
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
self.mylog.vprint("Converged through selecting minimum oscillating objective.")
|
|
1874
|
-
break
|
|
1849
|
+
for ii in range(self.A.ncons):
|
|
1850
|
+
task.putarow(ii, Aind[ii], Aval[ii])
|
|
1851
|
+
task.putconbound(ii, bdic[ckeys[ii]], clb[ii], cub[ii])
|
|
1875
1852
|
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
break
|
|
1853
|
+
task.putobjsense(mosek.objsense.minimize)
|
|
1854
|
+
task.optimize()
|
|
1879
1855
|
|
|
1880
|
-
|
|
1881
|
-
|
|
1856
|
+
solsta = task.getsolsta(mosek.soltype.itg)
|
|
1857
|
+
# prosta = task.getprosta(mosek.soltype.itg)
|
|
1882
1858
|
|
|
1883
1859
|
task.set_Stream(mosek.streamtype.wrn, _streamPrinter)
|
|
1884
1860
|
# task.writedata(self._name+'.ptf')
|
|
1885
1861
|
if solsta == mosek.solsta.integer_optimal:
|
|
1886
|
-
|
|
1862
|
+
xx = np.array(task.getxx(mosek.soltype.itg))
|
|
1863
|
+
solution = task.getprimalobj(mosek.soltype.itg)
|
|
1864
|
+
|
|
1865
|
+
self.mylog.vprint("Solution successful.")
|
|
1887
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
|
+
|
|
1888
1872
|
self.mylog.vprint("Objective:", u.d(solution * objFac))
|
|
1889
1873
|
self.caseStatus = "solved"
|
|
1890
1874
|
# self.mylog.vprint('Upper bound:', u.d(-solution.mip_dual_bound))
|
|
1891
|
-
self._aggregateResults(xx)
|
|
1875
|
+
self._aggregateResults(options, xx)
|
|
1892
1876
|
self._timestamp = datetime.now().strftime("%Y/%m/%d %H:%M:%S")
|
|
1893
1877
|
else:
|
|
1894
1878
|
self.mylog.vprint("WARNING: Optimization failed:", "Infeasible or unbounded.")
|
|
@@ -1912,11 +1896,11 @@ class Plan(object):
|
|
|
1912
1896
|
self.F_tn = self.F_tn.reshape((self.N_t, self.N_n))
|
|
1913
1897
|
MAGI_n = np.sum(self.F_tn, axis=0) + np.array(x[self.C["e"] : self.C["F"]])
|
|
1914
1898
|
|
|
1915
|
-
self.M_n = tx.mediCosts(self.yobs, self.horizons, MAGI_n, self.
|
|
1899
|
+
self.M_n = tx.mediCosts(self.yobs, self.horizons, MAGI_n, self.prevMAGIs, self.gamma_n[:-1], self.N_n)
|
|
1916
1900
|
|
|
1917
1901
|
return None
|
|
1918
1902
|
|
|
1919
|
-
def _aggregateResults(self, x):
|
|
1903
|
+
def _aggregateResults(self, options, x):
|
|
1920
1904
|
"""
|
|
1921
1905
|
Utility function to aggregate results from solver.
|
|
1922
1906
|
Process all results from solution vector.
|
|
@@ -1926,6 +1910,7 @@ class Plan(object):
|
|
|
1926
1910
|
Nj = self.N_j
|
|
1927
1911
|
Nk = self.N_k
|
|
1928
1912
|
Nn = self.N_n
|
|
1913
|
+
Nq = self.N_q
|
|
1929
1914
|
Nt = self.N_t
|
|
1930
1915
|
# Nz = self.N_z
|
|
1931
1916
|
n_d = self.n_d
|
|
@@ -1935,10 +1920,12 @@ class Plan(object):
|
|
|
1935
1920
|
Ce = self.C["e"]
|
|
1936
1921
|
CF = self.C["F"]
|
|
1937
1922
|
Cg = self.C["g"]
|
|
1923
|
+
Cm = self.C["m"]
|
|
1938
1924
|
Cs = self.C["s"]
|
|
1939
1925
|
Cw = self.C["w"]
|
|
1940
1926
|
Cx = self.C["x"]
|
|
1941
|
-
|
|
1927
|
+
Czx = self.C["zx"]
|
|
1928
|
+
Czm = self.C["zm"]
|
|
1942
1929
|
|
|
1943
1930
|
x = u.roundCents(x)
|
|
1944
1931
|
|
|
@@ -1957,19 +1944,28 @@ class Plan(object):
|
|
|
1957
1944
|
self.F_tn = np.array(x[CF:Cg])
|
|
1958
1945
|
self.F_tn = self.F_tn.reshape((Nt, Nn))
|
|
1959
1946
|
|
|
1960
|
-
self.g_n = np.array(x[Cg:
|
|
1947
|
+
self.g_n = np.array(x[Cg:Cm])
|
|
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)
|
|
1961
1953
|
|
|
1962
1954
|
self.s_n = np.array(x[Cs:Cw])
|
|
1963
1955
|
|
|
1964
1956
|
self.w_ijn = np.array(x[Cw:Cx])
|
|
1965
1957
|
self.w_ijn = self.w_ijn.reshape((Ni, Nj, Nn))
|
|
1966
1958
|
|
|
1967
|
-
self.x_in = np.array(x[Cx:
|
|
1959
|
+
self.x_in = np.array(x[Cx:Czx])
|
|
1968
1960
|
self.x_in = self.x_in.reshape((Ni, Nn))
|
|
1969
1961
|
|
|
1970
|
-
# self.
|
|
1971
|
-
# self.
|
|
1972
|
-
# print(self.
|
|
1962
|
+
# self.zx_inz = np.array(x[Czx:Czm])
|
|
1963
|
+
# self.zx_inz = self.zx_inz.reshape((Ni, Nn, Nz))
|
|
1964
|
+
# print(self.zx_inz)
|
|
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))
|
|
1973
1969
|
|
|
1974
1970
|
# Partial distribution at the passing of first spouse.
|
|
1975
1971
|
if Ni == 2 and n_d < Nn:
|
|
@@ -2158,10 +2154,10 @@ class Plan(object):
|
|
|
2158
2154
|
dic["Total tax paid on gains and dividends"] = f"{u.d(taxPaidNow)}"
|
|
2159
2155
|
dic["[Total tax paid on gains and dividends]"] = f"{u.d(taxPaid)}"
|
|
2160
2156
|
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
dic["Total Medicare premiums paid"] = f"{u.d(
|
|
2164
|
-
dic["[Total Medicare premiums paid]"] = f"{u.d(
|
|
2157
|
+
medtaxPaid = np.sum(self.m_n, axis=0)
|
|
2158
|
+
medtaxPaidNow = np.sum(self.m_n / self.gamma_n[:-1], axis=0)
|
|
2159
|
+
dic["Total Medicare premiums paid"] = f"{u.d(medtaxPaidNow)}"
|
|
2160
|
+
dic["[Total Medicare premiums paid]"] = f"{u.d(medtaxPaid)}"
|
|
2165
2161
|
|
|
2166
2162
|
if self.N_i == 2 and self.n_d < self.N_n:
|
|
2167
2163
|
p_j = self.partialEstate_j * (1 - self.phi_j)
|
|
@@ -2614,12 +2610,12 @@ class Plan(object):
|
|
|
2614
2610
|
style = {"income taxes": "-", "Medicare": "-."}
|
|
2615
2611
|
|
|
2616
2612
|
if value == "nominal":
|
|
2617
|
-
series = {"income taxes": self.T_n, "Medicare": self.
|
|
2613
|
+
series = {"income taxes": self.T_n, "Medicare": self.m_n}
|
|
2618
2614
|
yformat = "\\$k (nominal)"
|
|
2619
2615
|
else:
|
|
2620
2616
|
series = {
|
|
2621
2617
|
"income taxes": self.T_n / self.gamma_n[:-1],
|
|
2622
|
-
"Medicare": self.
|
|
2618
|
+
"Medicare": self.m_n / self.gamma_n[:-1],
|
|
2623
2619
|
}
|
|
2624
2620
|
yformat = "\\$k (" + str(self.year_n[0]) + "\\$)"
|
|
2625
2621
|
|
|
@@ -2754,7 +2750,7 @@ class Plan(object):
|
|
|
2754
2750
|
"net spending": self.g_n,
|
|
2755
2751
|
"taxable ord. income": self.G_n,
|
|
2756
2752
|
"taxable gains/divs": self.Q_n,
|
|
2757
|
-
"Tax bills + Med.": self.T_n + self.U_n + self.
|
|
2753
|
+
"Tax bills + Med.": self.T_n + self.U_n + self.m_n,
|
|
2758
2754
|
}
|
|
2759
2755
|
|
|
2760
2756
|
fillsheet(ws, incomeDic, "currency")
|
|
@@ -2770,7 +2766,7 @@ class Plan(object):
|
|
|
2770
2766
|
"all deposits": -np.sum(self.d_in, axis=0),
|
|
2771
2767
|
"ord taxes": -self.T_n,
|
|
2772
2768
|
"div taxes": -self.U_n,
|
|
2773
|
-
"Medicare": -self.
|
|
2769
|
+
"Medicare": -self.m_n,
|
|
2774
2770
|
}
|
|
2775
2771
|
sname = "Cash Flow"
|
|
2776
2772
|
ws = wb.create_sheet(sname)
|
owlplanner/tax2025.py
CHANGED
|
@@ -42,14 +42,15 @@ 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, 9999999],
|
|
46
|
+
[0, 212000, 266000, 334000, 400000, 750000, 9999999],
|
|
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)
|
|
53
54
|
|
|
54
55
|
# Make projection for non-TCJA using 2017 to current year.
|
|
55
56
|
# taxBrackets_2017 = np.array(
|
|
@@ -82,6 +83,34 @@ extra65Deduction = np.array([2000, 1600])
|
|
|
82
83
|
###############################################################################
|
|
83
84
|
|
|
84
85
|
|
|
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
|
+
|
|
85
114
|
def mediCosts(yobs, horizons, magi, prevmagi, gamma_n, Nn):
|
|
86
115
|
"""
|
|
87
116
|
Compute Medicare costs directly.
|
owlplanner/version.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "2025.04.
|
|
1
|
+
__version__ = "2025.04.28"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: owlplanner
|
|
3
|
-
Version: 2025.4.
|
|
3
|
+
Version: 2025.4.28
|
|
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,7 +719,8 @@ 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
|
|
722
|
+
- Run Owl directly on the Streamlit Community Server at
|
|
723
|
+
[owlplanner.streamlit.app](https://owlplanner.streamlit.app).
|
|
723
724
|
|
|
724
725
|
- Run locally on your computer using a Docker image.
|
|
725
726
|
Follow these [instructions](docker/README.md) for this option.
|
|
@@ -758,8 +759,8 @@ while providing a codebase where they can learn and contribute. There are and we
|
|
|
758
759
|
good retirement optimizers in the recent past, but the vast majority of them are either proprietary platforms
|
|
759
760
|
collecting your data, or academic papers that share the results without really sharing the details of
|
|
760
761
|
the underlying mathematical models.
|
|
761
|
-
The algorithms in Owl rely on the open-source HiGHS linear programming solver.
|
|
762
|
-
detailed description of the underlying
|
|
762
|
+
The algorithms in Owl rely on the open-source HiGHS linear programming solver.
|
|
763
|
+
The complete formulation and detailed description of the underlying
|
|
763
764
|
mathematical model can be found [here](https://raw.github.com/mdlacasse/Owl/main/docs/owl.pdf).
|
|
764
765
|
|
|
765
766
|
It is anticipated that most end users will use Owl through the graphical interface
|
|
@@ -772,16 +773,18 @@ Not every retirement decision strategy can be framed as an easy-to-solve optimiz
|
|
|
772
773
|
In particular, if one is interested in comparing different withdrawal strategies,
|
|
773
774
|
[FI Calc](ficalc.app) is an elegant application that addresses this need.
|
|
774
775
|
If, however, you also want to optimize spending, bequest, and Roth conversions, with
|
|
775
|
-
an approach also considering Medicare and federal income tax over the next few years,
|
|
776
|
+
an approach also considering Medicare costs and federal income tax over the next few years,
|
|
776
777
|
then Owl is definitely a tool that can help guide your decisions.
|
|
777
778
|
|
|
778
779
|
--------------------------------------------------------------------------------------
|
|
779
780
|
## Capabilities
|
|
780
|
-
Owl can optimize for either maximum net spending under the constraint
|
|
781
|
+
Owl can optimize for either the maximum net spending amount under the constraint
|
|
782
|
+
of a given bequest (which can be zero),
|
|
781
783
|
or maximize the after-tax value of a bequest under the constraint of a desired net spending profile,
|
|
782
|
-
|
|
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
|
|
784
|
+
under the assumption of a heirs marginal tax rate.
|
|
785
|
+
Roth conversions are also considered, subject to an optional maximum Roth conversion amount,
|
|
786
|
+
and optimized to suit the goals of the selected objective function, while considering
|
|
787
|
+
Medicare costs, and federal income tax under different rates of return assumptions.
|
|
785
788
|
All calculations are indexed for inflation, which is either provided as a fixed rate,
|
|
786
789
|
or through historical values, as are all other rates used for the calculations.
|
|
787
790
|
These rates can be used for backtesting different scenarios by choosing
|
|
@@ -839,29 +842,29 @@ which are all tracked separately for married individuals. Asset transition to th
|
|
|
839
842
|
is done according to beneficiary fractions for each type of savings account.
|
|
840
843
|
Tax status covers married filing jointly and single, depending on the number of individuals reported.
|
|
841
844
|
|
|
842
|
-
Medicare and IRMAA calculations are
|
|
845
|
+
Federal income tax, Medicare part B premiums, and IRMAA calculations are part of the optimization.
|
|
843
846
|
Future values are simple projections of current values with the assumed inflation rates.
|
|
844
847
|
|
|
845
848
|
### Limitations
|
|
846
849
|
Owl is work in progress. At the current time:
|
|
847
850
|
- Only the US federal income tax is considered (and minimized through the optimization algorithm).
|
|
848
851
|
Head of household filing status has not been added but can easily be.
|
|
849
|
-
- Required minimum distributions are
|
|
852
|
+
- Required minimum distributions are always performed and calculated,
|
|
853
|
+
but tables for spouses more than 10 years apart are not included.
|
|
850
854
|
These cases are detected and will generate an error message.
|
|
851
855
|
- 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.
|
|
854
856
|
- In the current implementation, social securiy is always taxed at 85%.
|
|
855
|
-
- Medicare calculations
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
for being conservative.
|
|
857
|
+
- Medicare calculations require the use of binary variables which add to the computational costs.
|
|
858
|
+
I've observed run requiring up to 10 seconds.
|
|
859
|
+
These calculations can be turned off when shorter time to solutions are required.
|
|
860
|
+
The use of a commercial solver (e.g. MOSEK) can substantially reduce computing time
|
|
861
|
+
but require a license.
|
|
861
862
|
- Part D is not included in the IRMAA calculations. Being considerably more significant,
|
|
862
863
|
only Part B is taken into account.
|
|
863
|
-
- Future tax brackets are pure speculations derived from the little we know now and projected
|
|
864
|
-
Your guesses are as good as mine.
|
|
864
|
+
- Future tax brackets are pure speculations derived from the little we know now and projected
|
|
865
|
+
to the next 30 years. Your guesses are as good as mine.
|
|
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.
|
|
865
868
|
|
|
866
869
|
The solution from an optimization algorithm has only two states: feasible and infeasible.
|
|
867
870
|
Therefore, unlike event-driven simulators that can tell you that your distribution strategy runs
|
|
@@ -874,7 +877,8 @@ assets to support, even with no estate being left.
|
|
|
874
877
|
---------------------------------------------------------------
|
|
875
878
|
## Documentation
|
|
876
879
|
|
|
877
|
-
- Documentation for the app user interface is available from the interface
|
|
880
|
+
- Documentation for the app user interface is available from the interface
|
|
881
|
+
[itself](https://owlplanner.streamlit.app/Documentation).
|
|
878
882
|
- Installation guide and software requirements can be found [here](INSTALL.md).
|
|
879
883
|
- User guide for the underlying Python package as used in a Jupyter notebook can be found [here](USER_GUIDE.md).
|
|
880
884
|
|
|
@@ -891,7 +895,7 @@ assets to support, even with no estate being left.
|
|
|
891
895
|
|
|
892
896
|
---------------------------------------------------------------------
|
|
893
897
|
|
|
894
|
-
Copyright ©
|
|
898
|
+
Copyright © 2025 - Martin-D. Lacasse
|
|
895
899
|
|
|
896
900
|
Disclaimers: I am not a financial planner. You make your own decisions.
|
|
897
901
|
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=xf5233Ph952y7O-m1vc0WmbTz3RlQtDemfqD40edlZ4,6710
|
|
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=_9MQWUEcElLbdZ-J0DqmcMim3Q9KrVmUpFPHtSiatZA,118775
|
|
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=yxpFhETe2fmo1qosKSrIX81z4fMMu4VcKSBd-ePSrTw,8913
|
|
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=vGljB4UJ5r4pxBlvSDiQ77nNw16bav9YeZM-RB6mrPo,28
|
|
12
12
|
owlplanner/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
13
13
|
owlplanner/data/rates.csv,sha256=6fxg56BVVORrj9wJlUGFdGXKvOX5r7CSca8uhUbbuIU,3734
|
|
14
|
-
owlplanner-2025.4.
|
|
15
|
-
owlplanner-2025.4.
|
|
16
|
-
owlplanner-2025.4.
|
|
17
|
-
owlplanner-2025.4.
|
|
14
|
+
owlplanner-2025.4.28.dist-info/METADATA,sha256=raP9OUYGLRQ_TL2udRcCM0j4rwtyLaHTFPYRv6lQ_VY,53939
|
|
15
|
+
owlplanner-2025.4.28.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
16
|
+
owlplanner-2025.4.28.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
|
|
17
|
+
owlplanner-2025.4.28.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|