owlplanner 2025.4.22__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 +273 -285
- owlplanner/tax2025.py +31 -2
- owlplanner/version.py +1 -1
- {owlplanner-2025.4.22.dist-info → owlplanner-2025.4.28.dist-info}/METADATA +27 -23
- {owlplanner-2025.4.22.dist-info → owlplanner-2025.4.28.dist-info}/RECORD +8 -9
- owlplanner/.plan.py.swo +0 -0
- {owlplanner-2025.4.22.dist-info → owlplanner-2025.4.28.dist-info}/WHEEL +0 -0
- {owlplanner-2025.4.22.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
|
|
@@ -1087,32 +1097,54 @@ class Plan(object):
|
|
|
1087
1097
|
Tau1_ijn = 1 + tau_ijn
|
|
1088
1098
|
Tauh_ijn = 1 + tau_ijn / 2
|
|
1089
1099
|
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
bigM = 5e6
|
|
1096
|
-
if "bigM" in options:
|
|
1097
|
-
# No units for bigM.
|
|
1098
|
-
bigM = options["bigM"]
|
|
1100
|
+
units = u.getUnits(options.get("units", "k"))
|
|
1101
|
+
# No units for bigM.
|
|
1102
|
+
bigM = options.get("bigM", 5e6)
|
|
1103
|
+
assert isinstance(bigM, (int, float)), f"bigM {bigM} is not a number."
|
|
1099
1104
|
|
|
1100
1105
|
###################################################################
|
|
1101
1106
|
# Inequality constraint matrix with upper and lower bound vectors.
|
|
1102
1107
|
A = abc.ConstraintMatrix(self.nvars)
|
|
1103
1108
|
B = abc.Bounds(self.nvars)
|
|
1104
1109
|
|
|
1105
|
-
#
|
|
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...
|
|
1106
1139
|
for i in range(Ni):
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
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)
|
|
1114
1146
|
|
|
1115
|
-
# Income tax bracket range inequalities.
|
|
1147
|
+
# Income tax bracket range inequalities, from 0 to upper bound.
|
|
1116
1148
|
for t in range(Nt):
|
|
1117
1149
|
for n in range(Nn):
|
|
1118
1150
|
B.set0_Ub(_q2(CF, t, n, Nt, Nn), self.DeltaBar_tn[t, n])
|
|
@@ -1121,8 +1153,31 @@ class Plan(object):
|
|
|
1121
1153
|
for n in range(Nn):
|
|
1122
1154
|
B.set0_Ub(_q1(Ce, n, Nn), self.sigmaBar_n[n])
|
|
1123
1155
|
|
|
1124
|
-
#
|
|
1125
|
-
|
|
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.
|
|
1126
1181
|
if "maxRothConversion" in options and options["maxRothConversion"] == "file":
|
|
1127
1182
|
# self.mylog.vprint(f"Fixing Roth conversions to those from file {self.timeListsFileName}.")
|
|
1128
1183
|
for i in range(Ni):
|
|
@@ -1167,59 +1222,13 @@ class Plan(object):
|
|
|
1167
1222
|
for n in range(Nn):
|
|
1168
1223
|
B.set0_Ub(_q2(Cx, i_x, n, Ni, Nn), zero)
|
|
1169
1224
|
|
|
1170
|
-
# Impose withdrawal limits on taxable and tax-exempt accounts.
|
|
1171
|
-
for i in range(Ni):
|
|
1172
|
-
for j in [0, 2]:
|
|
1173
|
-
for n in range(Nn):
|
|
1174
|
-
rowDic = {_q3(Cw, i, j, n, Ni, Nj, Nn): -1, _q3(Cb, i, j, n, Ni, Nj, Nn + 1): 1}
|
|
1175
|
-
A.addNewRow(rowDic, zero, inf)
|
|
1176
|
-
|
|
1177
|
-
# Impose withdrawals and conversion limits on tax-deferred account.
|
|
1178
|
-
for i in range(Ni):
|
|
1179
|
-
for n in range(Nn):
|
|
1180
|
-
rowDic = {
|
|
1181
|
-
_q2(Cx, i, n, Ni, Nn): -1,
|
|
1182
|
-
_q3(Cw, i, 1, n, Ni, Nj, Nn): -1,
|
|
1183
|
-
_q3(Cb, i, 1, n, Ni, Nj, Nn + 1): 1,
|
|
1184
|
-
}
|
|
1185
|
-
A.addNewRow(rowDic, zero, inf)
|
|
1186
|
-
|
|
1187
|
-
# Constraints depending on objective function.
|
|
1188
|
-
if objective == "maxSpending":
|
|
1189
|
-
# Impose optional constraint on final bequest requested in today's $.
|
|
1190
|
-
if "bequest" in options:
|
|
1191
|
-
bequest = options["bequest"]
|
|
1192
|
-
assert isinstance(bequest, (int, float)), "Desired bequest is not a number."
|
|
1193
|
-
bequest *= units * self.gamma_n[-1]
|
|
1194
|
-
else:
|
|
1195
|
-
# If not specified, defaults to $1 (nominal $).
|
|
1196
|
-
bequest = 1
|
|
1197
|
-
|
|
1198
|
-
row = A.newRow()
|
|
1199
|
-
for i in range(Ni):
|
|
1200
|
-
row.addElem(_q3(Cb, i, 0, Nn, Ni, Nj, Nn + 1), 1)
|
|
1201
|
-
row.addElem(_q3(Cb, i, 1, Nn, Ni, Nj, Nn + 1), 1 - self.nu)
|
|
1202
|
-
# Nudge could be added (e.g. 1.02) to artificially favor tax-exempt account
|
|
1203
|
-
# as heirs's benefits of 10y tax-free is not weighted in?
|
|
1204
|
-
row.addElem(_q3(Cb, i, 2, Nn, Ni, Nj, Nn + 1), 1)
|
|
1205
|
-
A.addRow(row, bequest, bequest)
|
|
1206
|
-
# self.mylog.vprint('Adding bequest constraint of:', u.d(bequest))
|
|
1207
|
-
elif objective == "maxBequest":
|
|
1208
|
-
spending = options["netSpending"]
|
|
1209
|
-
assert isinstance(spending, (int, float)), "Desired spending provided is not a number."
|
|
1210
|
-
# Account for time elapsed in the current year.
|
|
1211
|
-
spending *= units * self.yearFracLeft
|
|
1212
|
-
# self.mylog.vprint('Maximizing bequest with desired net spending of:', u.d(spending))
|
|
1213
|
-
# To allow slack in first year, Cg can be made Nn+1 and store basis in g[Nn].
|
|
1214
|
-
A.addNewRow({_q1(Cg, 0, Nn): 1}, spending, spending)
|
|
1215
|
-
|
|
1216
1225
|
# Set initial balances through constraints.
|
|
1217
1226
|
for i in range(Ni):
|
|
1218
1227
|
for j in range(Nj):
|
|
1219
1228
|
rhs = self.beta_ij[i, j]
|
|
1220
1229
|
A.addNewRow({_q3(Cb, i, j, 0, Ni, Nj, Nn + 1): 1}, rhs, rhs)
|
|
1221
1230
|
|
|
1222
|
-
# Link surplus and taxable account deposits
|
|
1231
|
+
# Link cash flow surplus and taxable account deposits even for Ni=1.
|
|
1223
1232
|
for i in range(Ni):
|
|
1224
1233
|
fac1 = u.krond(i, 0) * (1 - self.eta) + u.krond(i, 1) * self.eta
|
|
1225
1234
|
for n in range(n_d):
|
|
@@ -1233,63 +1242,51 @@ class Plan(object):
|
|
|
1233
1242
|
# No surplus allowed during the last year to be used as a tax loophole.
|
|
1234
1243
|
B.set0_Ub(_q1(Cs, Nn - 1, Nn), zero)
|
|
1235
1244
|
|
|
1236
|
-
if Ni == 2:
|
|
1237
|
-
# No conversion during last year.
|
|
1238
|
-
# B.set0_Ub(_q2(Cx, i_d, nd-1, Ni, Nn), zero)
|
|
1239
|
-
# B.set0_Ub(_q2(Cx, i_s, Nn-1, Ni, Nn), zero)
|
|
1240
|
-
|
|
1241
|
-
# No withdrawals or deposits for any i_d-owned accounts after year of passing.
|
|
1242
|
-
# Implicit n_d < Nn imposed by for loop.
|
|
1243
|
-
for n in range(n_d, Nn):
|
|
1244
|
-
B.set0_Ub(_q2(Cd, i_d, n, Ni, Nn), zero)
|
|
1245
|
-
B.set0_Ub(_q2(Cx, i_d, n, Ni, Nn), zero)
|
|
1246
|
-
for j in range(Nj):
|
|
1247
|
-
B.set0_Ub(_q3(Cw, i_d, j, n, Ni, Nj, Nn), zero)
|
|
1248
|
-
|
|
1249
1245
|
# Account balances carried from year to year.
|
|
1250
1246
|
# Considering spousal asset transfer at passing of a spouse.
|
|
1251
1247
|
# Using hybrid approach with 'if' statement and Kronecker deltas.
|
|
1252
1248
|
for i in range(Ni):
|
|
1253
1249
|
for j in range(Nj):
|
|
1254
1250
|
for n in range(Nn):
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
else:
|
|
1259
|
-
fac1 = 1
|
|
1260
|
-
|
|
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]
|
|
1261
1254
|
rhs = fac1 * self.kappa_ijn[i, j, n] * Tauh_ijn[i, j, n]
|
|
1262
1255
|
|
|
1263
1256
|
row = A.newRow()
|
|
1264
1257
|
row.addElem(_q3(Cb, i, j, n + 1, Ni, Nj, Nn + 1), 1)
|
|
1265
|
-
row.addElem(_q3(Cb, i, j, n, Ni, Nj, Nn + 1), -
|
|
1266
|
-
row.addElem(
|
|
1267
|
-
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)
|
|
1268
1261
|
row.addElem(
|
|
1269
1262
|
_q2(Cx, i, n, Ni, Nn),
|
|
1270
|
-
-
|
|
1263
|
+
-(u.krond(j, 2) - u.krond(j, 1)) * fac1_ijn,
|
|
1271
1264
|
)
|
|
1272
1265
|
|
|
1273
1266
|
if Ni == 2 and n_d < Nn and i == i_s and n == n_d - 1:
|
|
1274
1267
|
fac2 = self.phi_j[j]
|
|
1268
|
+
fac2_idjn = fac2 * Tau1_ijn[i_d, j, n]
|
|
1275
1269
|
rhs += fac2 * self.kappa_ijn[i_d, j, n] * Tauh_ijn[i_d, j, n]
|
|
1276
|
-
|
|
1277
|
-
row.addElem(_q3(
|
|
1278
|
-
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)
|
|
1279
1274
|
row.addElem(
|
|
1280
1275
|
_q2(Cx, i_d, n, Ni, Nn),
|
|
1281
|
-
-
|
|
1276
|
+
-(u.krond(j, 2) - u.krond(j, 1)) * fac2_idjn,
|
|
1282
1277
|
)
|
|
1283
1278
|
A.addRow(row, rhs, rhs)
|
|
1284
1279
|
|
|
1285
1280
|
tau_0prev = np.roll(self.tau_kn[0, :], 1)
|
|
1281
|
+
# No tax on losses, nor tax-loss harvesting.
|
|
1286
1282
|
tau_0prev[tau_0prev < 0] = 0
|
|
1287
1283
|
|
|
1288
|
-
#
|
|
1284
|
+
# Cash flow for net spending.
|
|
1289
1285
|
for n in range(Nn):
|
|
1290
|
-
rhs =
|
|
1291
|
-
row = A.newRow(
|
|
1292
|
-
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)
|
|
1293
1290
|
for i in range(Ni):
|
|
1294
1291
|
fac = self.psi * self.alpha_ijkn[i, 0, 0, n]
|
|
1295
1292
|
rhs += (
|
|
@@ -1301,13 +1298,13 @@ class Plan(object):
|
|
|
1301
1298
|
)
|
|
1302
1299
|
|
|
1303
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)
|
|
1304
1302
|
# Minus capital gains on taxable withdrawals using last year's rate if >=0.
|
|
1305
1303
|
# Plus taxable account withdrawals, and all other withdrawals.
|
|
1306
|
-
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))
|
|
1307
1305
|
penalty = 0.1 if n < self.n59[i] else 0
|
|
1308
1306
|
row.addElem(_q3(Cw, i, 1, n, Ni, Nj, Nn), -1 + penalty)
|
|
1309
1307
|
row.addElem(_q3(Cw, i, 2, n, Ni, Nj, Nn), -1 + penalty)
|
|
1310
|
-
row.addElem(_q2(Cd, i, n, Ni, Nn), fac * self.mu)
|
|
1311
1308
|
|
|
1312
1309
|
# Minus tax on ordinary income, T_n.
|
|
1313
1310
|
for t in range(Nt):
|
|
@@ -1315,7 +1312,7 @@ class Plan(object):
|
|
|
1315
1312
|
|
|
1316
1313
|
A.addRow(row, rhs, rhs)
|
|
1317
1314
|
|
|
1318
|
-
#
|
|
1315
|
+
# Enforce income profile.
|
|
1319
1316
|
for n in range(1, Nn):
|
|
1320
1317
|
rowDic = {_q1(Cg, 0, Nn): -spLo * self.xiBar_n[n], _q1(Cg, n, Nn): self.xiBar_n[0]}
|
|
1321
1318
|
A.addNewRow(rowDic, zero, inf)
|
|
@@ -1334,33 +1331,102 @@ class Plan(object):
|
|
|
1334
1331
|
row.addElem(_q2(Cx, i, n, Ni, Nn), -1)
|
|
1335
1332
|
|
|
1336
1333
|
# Taxable returns on securities in taxable account.
|
|
1337
|
-
|
|
1338
|
-
rhs += 0.5 *
|
|
1339
|
-
row.addElem(_q3(Cb, i, 0, n, Ni, Nj, Nn + 1), -
|
|
1340
|
-
row.addElem(_q3(Cw, i, 0, n, Ni, Nj, Nn),
|
|
1341
|
-
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)
|
|
1342
1339
|
|
|
1343
1340
|
for t in range(Nt):
|
|
1344
1341
|
row.addElem(_q2(CF, t, n, Nt, Nn), 1)
|
|
1345
1342
|
|
|
1346
1343
|
A.addRow(row, rhs, rhs)
|
|
1347
1344
|
|
|
1348
|
-
#
|
|
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.
|
|
1349
1414
|
for i in range(Ni):
|
|
1350
1415
|
for n in range(self.horizons[i]):
|
|
1416
|
+
# Configure binary variables.
|
|
1351
1417
|
for z in range(Nz):
|
|
1352
|
-
B.setBinary(_q3(
|
|
1418
|
+
B.setBinary(_q3(Czx, i, n, z, Ni, Nn, Nz))
|
|
1353
1419
|
|
|
1354
1420
|
# Exclude simultaneous deposits and withdrawals from taxable or tax-free accounts.
|
|
1355
1421
|
A.addNewRow(
|
|
1356
|
-
{_q3(
|
|
1422
|
+
{_q3(Czx, i, n, 0, Ni, Nn, Nz): bigM, _q1(Cs, n, Nn): -1},
|
|
1357
1423
|
zero,
|
|
1358
1424
|
bigM,
|
|
1359
1425
|
)
|
|
1360
1426
|
|
|
1361
1427
|
A.addNewRow(
|
|
1362
1428
|
{
|
|
1363
|
-
_q3(
|
|
1429
|
+
_q3(Czx, i, n, 0, Ni, Nn, Nz): bigM,
|
|
1364
1430
|
_q3(Cw, i, 0, n, Ni, Nj, Nn): 1,
|
|
1365
1431
|
_q3(Cw, i, 2, n, Ni, Nj, Nn): 1,
|
|
1366
1432
|
},
|
|
@@ -1370,13 +1436,13 @@ class Plan(object):
|
|
|
1370
1436
|
|
|
1371
1437
|
# Exclude simultaneous Roth conversions and tax-exempt withdrawals.
|
|
1372
1438
|
A.addNewRow(
|
|
1373
|
-
{_q3(
|
|
1439
|
+
{_q3(Czx, i, n, 1, Ni, Nn, Nz): bigM, _q2(Cx, i, n, Ni, Nn): -1},
|
|
1374
1440
|
zero,
|
|
1375
1441
|
bigM,
|
|
1376
1442
|
)
|
|
1377
1443
|
|
|
1378
1444
|
A.addNewRow(
|
|
1379
|
-
{_q3(
|
|
1445
|
+
{_q3(Czx, i, n, 1, Ni, Nn, Nz): bigM, _q3(Cw, i, 2, n, Ni, Nj, Nn): 1},
|
|
1380
1446
|
zero,
|
|
1381
1447
|
bigM,
|
|
1382
1448
|
)
|
|
@@ -1668,17 +1734,14 @@ class Plan(object):
|
|
|
1668
1734
|
if objective == "maxSpending" and "bequest" not in myoptions:
|
|
1669
1735
|
self.mylog.vprint("Using bequest of $1.")
|
|
1670
1736
|
|
|
1671
|
-
self.
|
|
1737
|
+
self.prevMAGIs = np.zeros(3)
|
|
1672
1738
|
if "previousMAGIs" in myoptions:
|
|
1673
1739
|
magi = myoptions["previousMAGIs"]
|
|
1674
1740
|
if len(magi) != 3:
|
|
1675
1741
|
raise ValueError("previousMAGIs must have 3 values.")
|
|
1676
1742
|
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
else:
|
|
1680
|
-
units = 1000
|
|
1681
|
-
self.prevMAGI = units * np.array(magi)
|
|
1743
|
+
units = u.getUnits(options.get("units", "k"))
|
|
1744
|
+
self.prevMAGIs = units * np.array(magi)
|
|
1682
1745
|
|
|
1683
1746
|
self.lambdha = 0
|
|
1684
1747
|
if "spendingSlack" in myoptions:
|
|
@@ -1712,76 +1775,31 @@ class Plan(object):
|
|
|
1712
1775
|
"""
|
|
1713
1776
|
from scipy import optimize
|
|
1714
1777
|
|
|
1715
|
-
withMedicare = True
|
|
1716
|
-
if "withMedicare" in options and options["withMedicare"] is False:
|
|
1717
|
-
withMedicare = False
|
|
1718
|
-
|
|
1719
|
-
if objective == "maxSpending":
|
|
1720
|
-
objFac = -1 / self.xi_n[0]
|
|
1721
|
-
else:
|
|
1722
|
-
objFac = -1 / self.gamma_n[-1]
|
|
1723
|
-
|
|
1724
1778
|
# mip_rel_gap smaller than 1e-6 can lead to oscillatory solutions.
|
|
1725
|
-
milpOptions = {"disp": False, "mip_rel_gap": 1e-
|
|
1726
|
-
|
|
1727
|
-
it = 0
|
|
1728
|
-
absdiff = np.inf
|
|
1729
|
-
old_x = np.zeros(self.nvars)
|
|
1730
|
-
old_solutions = [np.inf]
|
|
1731
|
-
self._estimateMedicare(None, withMedicare)
|
|
1732
|
-
while True:
|
|
1733
|
-
self._buildConstraints(objective, options)
|
|
1734
|
-
Alu, lbvec, ubvec = self.A.arrays()
|
|
1735
|
-
Lb, Ub = self.B.arrays()
|
|
1736
|
-
integrality = self.B.integralityArray()
|
|
1737
|
-
c = self.c.arrays()
|
|
1738
|
-
|
|
1739
|
-
bounds = optimize.Bounds(Lb, Ub)
|
|
1740
|
-
constraint = optimize.LinearConstraint(Alu, lbvec, ubvec)
|
|
1741
|
-
solution = optimize.milp(
|
|
1742
|
-
c,
|
|
1743
|
-
integrality=integrality,
|
|
1744
|
-
constraints=constraint,
|
|
1745
|
-
bounds=bounds,
|
|
1746
|
-
options=milpOptions,
|
|
1747
|
-
)
|
|
1748
|
-
it += 1
|
|
1749
|
-
|
|
1750
|
-
if not solution.success:
|
|
1751
|
-
break
|
|
1752
|
-
|
|
1753
|
-
if not withMedicare:
|
|
1754
|
-
break
|
|
1755
|
-
|
|
1756
|
-
self._estimateMedicare(solution.x)
|
|
1779
|
+
milpOptions = {"disp": False, "mip_rel_gap": 1e-7}
|
|
1757
1780
|
|
|
1758
|
-
|
|
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()
|
|
1759
1786
|
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
break
|
|
1765
|
-
|
|
1766
|
-
# Avoid oscillatory solutions. Look only at most recent solutions. Within $10.
|
|
1767
|
-
isclosenough = abs(-solution.fun - min(old_solutions[int(it / 2) :])) < 10 * self.xi_n[0]
|
|
1768
|
-
if isclosenough:
|
|
1769
|
-
self.mylog.vprint("Converged through selecting minimum oscillating objective.")
|
|
1770
|
-
break
|
|
1771
|
-
|
|
1772
|
-
if it > 59:
|
|
1773
|
-
self.mylog.vprint("WARNING: Exiting loop on maximum iterations.")
|
|
1774
|
-
break
|
|
1775
|
-
|
|
1776
|
-
old_solutions.append(-solution.fun)
|
|
1777
|
-
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)
|
|
1778
1791
|
|
|
1779
1792
|
if solution.success:
|
|
1780
|
-
self.mylog.vprint(
|
|
1793
|
+
self.mylog.vprint("Solution successful.")
|
|
1781
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
|
+
|
|
1782
1800
|
self.mylog.vprint(f"Objective: {u.d(solution.fun * objFac)}")
|
|
1783
1801
|
# self.mylog.vprint('Upper bound:', u.d(-solution.mip_dual_bound))
|
|
1784
|
-
self._aggregateResults(solution.x)
|
|
1802
|
+
self._aggregateResults(options, solution.x)
|
|
1785
1803
|
self._timestamp = datetime.now().strftime("%Y-%m-%d at %H:%M:%S")
|
|
1786
1804
|
self.caseStatus = "solved"
|
|
1787
1805
|
else:
|
|
@@ -1796,17 +1814,6 @@ class Plan(object):
|
|
|
1796
1814
|
"""
|
|
1797
1815
|
import mosek
|
|
1798
1816
|
|
|
1799
|
-
withMedicare = True
|
|
1800
|
-
if "withMedicare" in options and options["withMedicare"] is False:
|
|
1801
|
-
withMedicare = False
|
|
1802
|
-
|
|
1803
|
-
if objective == "maxSpending":
|
|
1804
|
-
objFac = -1 / self.xi_n[0]
|
|
1805
|
-
else:
|
|
1806
|
-
objFac = -1 / self.gamma_n[-1]
|
|
1807
|
-
|
|
1808
|
-
# mip_rel_gap smaller than 1e-6 can lead to oscillatory solutions.
|
|
1809
|
-
|
|
1810
1817
|
bdic = {
|
|
1811
1818
|
"fx": mosek.boundkey.fx,
|
|
1812
1819
|
"fr": mosek.boundkey.fr,
|
|
@@ -1815,88 +1822,57 @@ class Plan(object):
|
|
|
1815
1822
|
"up": mosek.boundkey.up,
|
|
1816
1823
|
}
|
|
1817
1824
|
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
self.
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
Aind, Aval, clb, cub = self.A.lists()
|
|
1826
|
-
ckeys = self.A.keys()
|
|
1827
|
-
vlb, vub = self.B.arrays()
|
|
1828
|
-
integrality = self.B.integralityList()
|
|
1829
|
-
vkeys = self.B.keys()
|
|
1830
|
-
cind, cval = self.c.lists()
|
|
1831
|
-
|
|
1832
|
-
task = mosek.Task()
|
|
1833
|
-
# task.putdouparam(mosek.dparam.mio_rel_gap_const, 1e-5)
|
|
1834
|
-
# task.putdouparam(mosek.dparam.mio_tol_abs_relax_int, 1e-4)
|
|
1835
|
-
# task.set_Stream(mosek.streamtype.msg, _streamPrinter)
|
|
1836
|
-
task.appendcons(self.A.ncons)
|
|
1837
|
-
task.appendvars(self.A.nvars)
|
|
1838
|
-
|
|
1839
|
-
for ii in range(len(cind)):
|
|
1840
|
-
task.putcj(cind[ii], cval[ii])
|
|
1841
|
-
|
|
1842
|
-
for ii in range(self.nvars):
|
|
1843
|
-
task.putvarbound(ii, bdic[vkeys[ii]], vlb[ii], vub[ii])
|
|
1844
|
-
|
|
1845
|
-
for ii in range(len(integrality)):
|
|
1846
|
-
task.putvartype(integrality[ii], mosek.variabletype.type_int)
|
|
1847
|
-
|
|
1848
|
-
for ii in range(self.A.ncons):
|
|
1849
|
-
task.putarow(ii, Aind[ii], Aval[ii])
|
|
1850
|
-
task.putconbound(ii, bdic[ckeys[ii]], clb[ii], cub[ii])
|
|
1851
|
-
|
|
1852
|
-
task.putobjsense(mosek.objsense.minimize)
|
|
1853
|
-
task.optimize()
|
|
1854
|
-
|
|
1855
|
-
solsta = task.getsolsta(mosek.soltype.itg)
|
|
1856
|
-
# prosta = task.getprosta(mosek.soltype.itg)
|
|
1857
|
-
it += 1
|
|
1858
|
-
|
|
1859
|
-
if solsta != mosek.solsta.integer_optimal:
|
|
1860
|
-
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()
|
|
1861
1832
|
|
|
1862
|
-
|
|
1863
|
-
|
|
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)
|
|
1864
1839
|
|
|
1865
|
-
|
|
1866
|
-
|
|
1840
|
+
for ii in range(len(cind)):
|
|
1841
|
+
task.putcj(cind[ii], cval[ii])
|
|
1867
1842
|
|
|
1868
|
-
|
|
1843
|
+
for ii in range(self.nvars):
|
|
1844
|
+
task.putvarbound(ii, bdic[vkeys[ii]], vlb[ii], vub[ii])
|
|
1869
1845
|
|
|
1870
|
-
|
|
1846
|
+
for ii in range(len(integrality)):
|
|
1847
|
+
task.putvartype(integrality[ii], mosek.variabletype.type_int)
|
|
1871
1848
|
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
self.mylog.vprint("Converged on full solution.")
|
|
1876
|
-
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])
|
|
1877
1852
|
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
if isclosenough:
|
|
1881
|
-
self.mylog.vprint("Converged through selecting minimum oscillating objective.")
|
|
1882
|
-
break
|
|
1853
|
+
task.putobjsense(mosek.objsense.minimize)
|
|
1854
|
+
task.optimize()
|
|
1883
1855
|
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
break
|
|
1887
|
-
|
|
1888
|
-
old_solutions.append(-solution)
|
|
1889
|
-
old_x = xx
|
|
1856
|
+
solsta = task.getsolsta(mosek.soltype.itg)
|
|
1857
|
+
# prosta = task.getprosta(mosek.soltype.itg)
|
|
1890
1858
|
|
|
1891
1859
|
task.set_Stream(mosek.streamtype.wrn, _streamPrinter)
|
|
1892
1860
|
# task.writedata(self._name+'.ptf')
|
|
1893
1861
|
if solsta == mosek.solsta.integer_optimal:
|
|
1894
|
-
|
|
1862
|
+
xx = np.array(task.getxx(mosek.soltype.itg))
|
|
1863
|
+
solution = task.getprimalobj(mosek.soltype.itg)
|
|
1864
|
+
|
|
1865
|
+
self.mylog.vprint("Solution successful.")
|
|
1895
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
|
+
|
|
1896
1872
|
self.mylog.vprint("Objective:", u.d(solution * objFac))
|
|
1897
1873
|
self.caseStatus = "solved"
|
|
1898
1874
|
# self.mylog.vprint('Upper bound:', u.d(-solution.mip_dual_bound))
|
|
1899
|
-
self._aggregateResults(xx)
|
|
1875
|
+
self._aggregateResults(options, xx)
|
|
1900
1876
|
self._timestamp = datetime.now().strftime("%Y/%m/%d %H:%M:%S")
|
|
1901
1877
|
else:
|
|
1902
1878
|
self.mylog.vprint("WARNING: Optimization failed:", "Infeasible or unbounded.")
|
|
@@ -1920,11 +1896,11 @@ class Plan(object):
|
|
|
1920
1896
|
self.F_tn = self.F_tn.reshape((self.N_t, self.N_n))
|
|
1921
1897
|
MAGI_n = np.sum(self.F_tn, axis=0) + np.array(x[self.C["e"] : self.C["F"]])
|
|
1922
1898
|
|
|
1923
|
-
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)
|
|
1924
1900
|
|
|
1925
1901
|
return None
|
|
1926
1902
|
|
|
1927
|
-
def _aggregateResults(self, x):
|
|
1903
|
+
def _aggregateResults(self, options, x):
|
|
1928
1904
|
"""
|
|
1929
1905
|
Utility function to aggregate results from solver.
|
|
1930
1906
|
Process all results from solution vector.
|
|
@@ -1934,6 +1910,7 @@ class Plan(object):
|
|
|
1934
1910
|
Nj = self.N_j
|
|
1935
1911
|
Nk = self.N_k
|
|
1936
1912
|
Nn = self.N_n
|
|
1913
|
+
Nq = self.N_q
|
|
1937
1914
|
Nt = self.N_t
|
|
1938
1915
|
# Nz = self.N_z
|
|
1939
1916
|
n_d = self.n_d
|
|
@@ -1943,10 +1920,12 @@ class Plan(object):
|
|
|
1943
1920
|
Ce = self.C["e"]
|
|
1944
1921
|
CF = self.C["F"]
|
|
1945
1922
|
Cg = self.C["g"]
|
|
1923
|
+
Cm = self.C["m"]
|
|
1946
1924
|
Cs = self.C["s"]
|
|
1947
1925
|
Cw = self.C["w"]
|
|
1948
1926
|
Cx = self.C["x"]
|
|
1949
|
-
|
|
1927
|
+
Czx = self.C["zx"]
|
|
1928
|
+
Czm = self.C["zm"]
|
|
1950
1929
|
|
|
1951
1930
|
x = u.roundCents(x)
|
|
1952
1931
|
|
|
@@ -1965,19 +1944,28 @@ class Plan(object):
|
|
|
1965
1944
|
self.F_tn = np.array(x[CF:Cg])
|
|
1966
1945
|
self.F_tn = self.F_tn.reshape((Nt, Nn))
|
|
1967
1946
|
|
|
1968
|
-
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)
|
|
1969
1953
|
|
|
1970
1954
|
self.s_n = np.array(x[Cs:Cw])
|
|
1971
1955
|
|
|
1972
1956
|
self.w_ijn = np.array(x[Cw:Cx])
|
|
1973
1957
|
self.w_ijn = self.w_ijn.reshape((Ni, Nj, Nn))
|
|
1974
1958
|
|
|
1975
|
-
self.x_in = np.array(x[Cx:
|
|
1959
|
+
self.x_in = np.array(x[Cx:Czx])
|
|
1976
1960
|
self.x_in = self.x_in.reshape((Ni, Nn))
|
|
1977
1961
|
|
|
1978
|
-
# self.
|
|
1979
|
-
# self.
|
|
1980
|
-
# 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))
|
|
1981
1969
|
|
|
1982
1970
|
# Partial distribution at the passing of first spouse.
|
|
1983
1971
|
if Ni == 2 and n_d < Nn:
|
|
@@ -2166,10 +2154,10 @@ class Plan(object):
|
|
|
2166
2154
|
dic["Total tax paid on gains and dividends"] = f"{u.d(taxPaidNow)}"
|
|
2167
2155
|
dic["[Total tax paid on gains and dividends]"] = f"{u.d(taxPaid)}"
|
|
2168
2156
|
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
dic["Total Medicare premiums paid"] = f"{u.d(
|
|
2172
|
-
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)}"
|
|
2173
2161
|
|
|
2174
2162
|
if self.N_i == 2 and self.n_d < self.N_n:
|
|
2175
2163
|
p_j = self.partialEstate_j * (1 - self.phi_j)
|
|
@@ -2622,12 +2610,12 @@ class Plan(object):
|
|
|
2622
2610
|
style = {"income taxes": "-", "Medicare": "-."}
|
|
2623
2611
|
|
|
2624
2612
|
if value == "nominal":
|
|
2625
|
-
series = {"income taxes": self.T_n, "Medicare": self.
|
|
2613
|
+
series = {"income taxes": self.T_n, "Medicare": self.m_n}
|
|
2626
2614
|
yformat = "\\$k (nominal)"
|
|
2627
2615
|
else:
|
|
2628
2616
|
series = {
|
|
2629
2617
|
"income taxes": self.T_n / self.gamma_n[:-1],
|
|
2630
|
-
"Medicare": self.
|
|
2618
|
+
"Medicare": self.m_n / self.gamma_n[:-1],
|
|
2631
2619
|
}
|
|
2632
2620
|
yformat = "\\$k (" + str(self.year_n[0]) + "\\$)"
|
|
2633
2621
|
|
|
@@ -2762,7 +2750,7 @@ class Plan(object):
|
|
|
2762
2750
|
"net spending": self.g_n,
|
|
2763
2751
|
"taxable ord. income": self.G_n,
|
|
2764
2752
|
"taxable gains/divs": self.Q_n,
|
|
2765
|
-
"Tax bills + Med.": self.T_n + self.U_n + self.
|
|
2753
|
+
"Tax bills + Med.": self.T_n + self.U_n + self.m_n,
|
|
2766
2754
|
}
|
|
2767
2755
|
|
|
2768
2756
|
fillsheet(ws, incomeDic, "currency")
|
|
@@ -2778,7 +2766,7 @@ class Plan(object):
|
|
|
2778
2766
|
"all deposits": -np.sum(self.d_in, axis=0),
|
|
2779
2767
|
"ord taxes": -self.T_n,
|
|
2780
2768
|
"div taxes": -self.U_n,
|
|
2781
|
-
"Medicare": -self.
|
|
2769
|
+
"Medicare": -self.m_n,
|
|
2782
2770
|
}
|
|
2783
2771
|
sname = "Cash Flow"
|
|
2784
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,18 +1,17 @@
|
|
|
1
|
-
owlplanner/.plan.py.swo,sha256=6DmRbuBtsIhjhN44-D5GbxY2mfnsoIJpT-fQsFMDGNo,4096
|
|
2
1
|
owlplanner/__init__.py,sha256=QqrdT0Qks20osBTg7h0vJHAxpP9lL7DA99xb0nYbtw4,254
|
|
3
|
-
owlplanner/abcapi.py,sha256=
|
|
2
|
+
owlplanner/abcapi.py,sha256=xf5233Ph952y7O-m1vc0WmbTz3RlQtDemfqD40edlZ4,6710
|
|
4
3
|
owlplanner/config.py,sha256=F6GS3n02VeFX0GCVeM4J7Ra0in4N632W6TZIXk7Yj2w,12519
|
|
5
4
|
owlplanner/logging.py,sha256=tYMw04O-XYSzjTj36fmKJGLcE1VkK6k6oJNeqtKXzuc,2530
|
|
6
|
-
owlplanner/plan.py,sha256=
|
|
5
|
+
owlplanner/plan.py,sha256=_9MQWUEcElLbdZ-J0DqmcMim3Q9KrVmUpFPHtSiatZA,118775
|
|
7
6
|
owlplanner/progress.py,sha256=8jlCvvtgDI89zXVNMBg1-lnEyhpPvKQS2X5oAIpoOVQ,384
|
|
8
7
|
owlplanner/rates.py,sha256=gJaoe-gJqWCQV5qVLlHp-Yn9TSJs-PJzeTbOwMCbqWs,15682
|
|
9
|
-
owlplanner/tax2025.py,sha256=
|
|
8
|
+
owlplanner/tax2025.py,sha256=yxpFhETe2fmo1qosKSrIX81z4fMMu4VcKSBd-ePSrTw,8913
|
|
10
9
|
owlplanner/timelists.py,sha256=tYieZU67FT6TCcQQis36JaXGI7dT6NqD7RvdEjgJL4M,4026
|
|
11
10
|
owlplanner/utils.py,sha256=WpJgn79YZfH8UCkcmhd-AZlxlGuz1i1-UDBRXImsY6I,2485
|
|
12
|
-
owlplanner/version.py,sha256=
|
|
11
|
+
owlplanner/version.py,sha256=vGljB4UJ5r4pxBlvSDiQ77nNw16bav9YeZM-RB6mrPo,28
|
|
13
12
|
owlplanner/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
14
13
|
owlplanner/data/rates.csv,sha256=6fxg56BVVORrj9wJlUGFdGXKvOX5r7CSca8uhUbbuIU,3734
|
|
15
|
-
owlplanner-2025.4.
|
|
16
|
-
owlplanner-2025.4.
|
|
17
|
-
owlplanner-2025.4.
|
|
18
|
-
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,,
|
owlplanner/.plan.py.swo
DELETED
|
Binary file
|
|
File without changes
|
|
File without changes
|