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 CHANGED
@@ -97,6 +97,8 @@ class ConstraintMatrix(object):
97
97
  self.ub.append(ub)
98
98
  if lb == ub:
99
99
  self.key.append("fx")
100
+ elif lb == -np.inf:
101
+ self.key.append("up")
100
102
  elif ub == np.inf:
101
103
  self.key.append("lo")
102
104
  else:
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 3 years for Medicare.
308
- self.prevMAGI = np.zeros((3))
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
- # Stack all variables in a single block vector.
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["s"] = _qC(C["g"], self.N_n)
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["z"] = _qC(C["x"], self.N_i, self.N_n)
1040
- self.nvars = _qC(C["z"], self.N_i, self.N_n, self.N_z)
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
- Cz = self.C["z"]
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
- if "units" in options:
1091
- units = u.getUnits(options["units"])
1092
- else:
1093
- units = 1000
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
- # RMDs inequalities, only if there is an initial balance in tax-deferred account.
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
- if self.beta_ij[i, 1] > 0:
1108
- for n in range(self.horizons[i]):
1109
- rowDic = {
1110
- _q3(Cw, i, 1, n, Ni, Nj, Nn): 1,
1111
- _q3(Cb, i, 1, n, Ni, Nj, Nn + 1): -self.rho_in[i, n],
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
- # Roth conversions equalities/inequalities.
1125
- # This condition supercedes everything else.
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 regardless of Ni.
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
- if Ni == 2 and n_d < Nn and i == i_d and n == n_d - 1:
1256
- # fac1 = 1 - (u.krond(n, n_d - 1) * u.krond(i, i_d))
1257
- fac1 = 0
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), -fac1 * Tau1_ijn[i, j, n])
1266
- row.addElem(_q3(Cw, i, j, n, Ni, Nj, Nn), fac1 * Tau1_ijn[i, j, n])
1267
- row.addElem(_q2(Cd, i, n, Ni, Nn), -fac1 * u.krond(j, 0) * Tau1_ijn[i, 0, n])
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
- -fac1 * (u.krond(j, 2) - u.krond(j, 1)) * Tau1_ijn[i, j, n],
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
- row.addElem(_q3(Cb, i_d, j, n, Ni, Nj, Nn + 1), -fac2 * Tau1_ijn[i_d, j, n])
1277
- row.addElem(_q3(Cw, i_d, j, n, Ni, Nj, Nn), fac2 * Tau1_ijn[i_d, j, n])
1278
- row.addElem(_q2(Cd, i_d, n, Ni, Nn), -fac2 * u.krond(j, 0) * Tau1_ijn[i_d, 0, n])
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
- -fac2 * (u.krond(j, 2) - u.krond(j, 1)) * Tau1_ijn[i_d, j, n],
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
- # Net cash flow.
1284
+ # Cash flow for net spending.
1289
1285
  for n in range(Nn):
1290
- rhs = -self.M_n[n]
1291
- row = A.newRow({_q1(Cg, n, Nn): 1})
1292
- row.addElem(_q1(Cs, n, Nn), 1)
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) - 1)
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
- # Impose income profile.
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
- fak = np.sum(self.tau_kn[1:Nk, n] * self.alpha_ijkn[i, 0, 1:Nk, n], axis=0)
1338
- rhs += 0.5 * fak * self.kappa_ijn[i, 0, n]
1339
- row.addElem(_q3(Cb, i, 0, n, Ni, Nj, Nn + 1), -fak)
1340
- row.addElem(_q3(Cw, i, 0, n, Ni, Nj, Nn), fak)
1341
- row.addElem(_q2(Cd, i, n, Ni, Nn), -fak)
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
- # Configure binary variables.
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(Cz, i, n, z, Ni, Nn, Nz))
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(Cz, i, n, 0, Ni, Nn, Nz): bigM, _q1(Cs, n, Nn): -1},
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(Cz, i, n, 0, Ni, Nn, Nz): bigM,
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(Cz, i, n, 1, Ni, Nn, Nz): bigM, _q2(Cx, i, n, Ni, Nn): -1},
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(Cz, i, n, 1, Ni, Nn, Nz): bigM, _q3(Cw, i, 2, n, Ni, Nj, Nn): 1},
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.prevMAGI = np.zeros(3)
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
- if "units" in options:
1678
- units = u.getUnits(options["units"])
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-6}
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
- self.mylog.vprint(f"Iteration: {it} objective: {u.d(solution.fun * objFac, f=2)}")
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
- delta = solution.x - old_x
1761
- absdiff = np.sum(np.abs(delta), axis=0)
1762
- if absdiff < 1:
1763
- self.mylog.vprint("Converged on full solution.")
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(f"Self-consistent Medicare loop returned after {it} iterations.")
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
- it = 0
1819
- absdiff = np.inf
1820
- old_x = np.zeros(self.nvars)
1821
- old_solutions = [np.inf]
1822
- self._estimateMedicare(None, withMedicare)
1823
- while True:
1824
- self._buildConstraints(objective, options)
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
- xx = np.array(task.getxx(mosek.soltype.itg))
1863
- solution = task.getprimalobj(mosek.soltype.itg)
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
- if withMedicare is False:
1866
- break
1840
+ for ii in range(len(cind)):
1841
+ task.putcj(cind[ii], cval[ii])
1867
1842
 
1868
- self._estimateMedicare(xx)
1843
+ for ii in range(self.nvars):
1844
+ task.putvarbound(ii, bdic[vkeys[ii]], vlb[ii], vub[ii])
1869
1845
 
1870
- self.mylog.vprint("Iteration:", it, "objective:", u.d(solution * objFac, f=2))
1846
+ for ii in range(len(integrality)):
1847
+ task.putvartype(integrality[ii], mosek.variabletype.type_int)
1871
1848
 
1872
- delta = xx - old_x
1873
- absdiff = np.sum(np.abs(delta), axis=0)
1874
- if absdiff < 1:
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
- # Avoid oscillatory solutions. Look only at most recent solutions. Within $10.
1879
- isclosenough = abs(-solution - min(old_solutions[int(it / 2) :])) < 10 * self.xi_n[0]
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
- if it > 59:
1885
- self.mylog.vprint("WARNING: Exiting loop on maximum iterations.")
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
- self.mylog.vprint(f"Self-consistent Medicare loop returned after {it} iterations.")
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.prevMAGI, self.gamma_n[:-1], self.N_n)
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
- Cz = self.C["z"]
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:Cs])
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:Cz])
1959
+ self.x_in = np.array(x[Cx:Czx])
1976
1960
  self.x_in = self.x_in.reshape((Ni, Nn))
1977
1961
 
1978
- # self.z_inz = np.array(x[Cz:])
1979
- # self.z_inz = self.z_inz.reshape((Ni, Nn, Nz))
1980
- # print(self.z_inz)
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
- taxPaid = np.sum(self.M_n, axis=0)
2170
- taxPaidNow = np.sum(self.M_n / self.gamma_n[:-1], axis=0)
2171
- dic["Total Medicare premiums paid"] = f"{u.d(taxPaidNow)}"
2172
- dic["[Total Medicare premiums paid]"] = f"{u.d(taxPaid)}"
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.M_n}
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.M_n / self.gamma_n[:-1],
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.M_n,
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.M_n,
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.22"
1
+ __version__ = "2025.04.28"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: owlplanner
3
- Version: 2025.4.22
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 [owlplanner.streamlit.app](https://owlplanner.streamlit.app).
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. The complete formulation and
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 of a given bequest (which can be zero),
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
- and under the assumption of a heirs marginal tax rate.
783
- Roth conversions are also considered, subject to an optional maximum conversion amount,
784
- and optimized to suit the goals of the selected objective function.
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 performed through a self-consistent loop on cash flow constraints.
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 calculated, but tables for spouses more than 10 years apart are not included.
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 are done through a self-consistent loop.
856
- This means that the Medicare premiums are calculated after an initial solution is generated,
857
- and then a new solution is re-generated with these premiums as a constraint.
858
- In some situations, when the income (MAGI) is near an IRMAA bracket, oscillatory solutions can arise.
859
- While the solutions generated are very close to one another, Owl will pick the smallest solution
860
- for being conservative.
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 to the next 30 years.
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 [itself](https://owlplanner.streamlit.app/Documentation).
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 &copy; 2024 - Martin-D. Lacasse
898
+ Copyright &copy; 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=LbzW_KcNy0IeHp42MUHwGu_H67B2h_e1_vu-c2ACTkQ,6646
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=c8_nOMiNXFiVDKqSxBbzYTZhgYvQJecJnZwAZqBmryE,117670
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=JDBtFFAf2bWtKUMuE3W5F0nBhYaKBjmdJj0iayM2iGA,7829
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=hMtSc1ylUesUgt89a7O-P3iBLxQCOd1RsXXs1SsFL8g,28
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.22.dist-info/METADATA,sha256=hVV4m0iYvXfqCm0dMDRxuvQR-EUCv1mGvwp9e6jfElw,53927
16
- owlplanner-2025.4.22.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
17
- owlplanner-2025.4.22.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
18
- owlplanner-2025.4.22.dist-info/RECORD,,
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