owlplanner 2025.4.26__py3-none-any.whl → 2025.4.28__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
owlplanner/abcapi.py 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
@@ -1097,17 +1107,44 @@ class Plan(object):
1097
1107
  A = abc.ConstraintMatrix(self.nvars)
1098
1108
  B = abc.Bounds(self.nvars)
1099
1109
 
1100
- # 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...
1101
1139
  for i in range(Ni):
1102
- if self.beta_ij[i, 1] > 0:
1103
- for n in range(self.horizons[i]):
1104
- rowDic = {
1105
- _q3(Cw, i, 1, n, Ni, Nj, Nn): 1,
1106
- _q3(Cb, i, 1, n, Ni, Nj, Nn + 1): -self.rho_in[i, n],
1107
- }
1108
- A.addNewRow(rowDic, zero, inf)
1140
+ for n in range(self.horizons[i]):
1141
+ rowDic = {
1142
+ _q3(Cw, i, 1, n, Ni, Nj, Nn): 1,
1143
+ _q3(Cb, i, 1, n, Ni, Nj, Nn + 1): -self.rho_in[i, n],
1144
+ }
1145
+ A.addNewRow(rowDic, zero, inf)
1109
1146
 
1110
- # Income tax bracket range inequalities.
1147
+ # Income tax bracket range inequalities, from 0 to upper bound.
1111
1148
  for t in range(Nt):
1112
1149
  for n in range(Nn):
1113
1150
  B.set0_Ub(_q2(CF, t, n, Nt, Nn), self.DeltaBar_tn[t, n])
@@ -1116,8 +1153,31 @@ class Plan(object):
1116
1153
  for n in range(Nn):
1117
1154
  B.set0_Ub(_q1(Ce, n, Nn), self.sigmaBar_n[n])
1118
1155
 
1119
- # Roth conversions equalities/inequalities.
1120
- # 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.
1121
1181
  if "maxRothConversion" in options and options["maxRothConversion"] == "file":
1122
1182
  # self.mylog.vprint(f"Fixing Roth conversions to those from file {self.timeListsFileName}.")
1123
1183
  for i in range(Ni):
@@ -1162,59 +1222,13 @@ class Plan(object):
1162
1222
  for n in range(Nn):
1163
1223
  B.set0_Ub(_q2(Cx, i_x, n, Ni, Nn), zero)
1164
1224
 
1165
- # Impose withdrawal limits on taxable and tax-exempt accounts.
1166
- for i in range(Ni):
1167
- for j in [0, 2]:
1168
- for n in range(Nn):
1169
- rowDic = {_q3(Cw, i, j, n, Ni, Nj, Nn): -1, _q3(Cb, i, j, n, Ni, Nj, Nn + 1): 1}
1170
- A.addNewRow(rowDic, zero, inf)
1171
-
1172
- # Impose withdrawals and conversion limits on tax-deferred account.
1173
- for i in range(Ni):
1174
- for n in range(Nn):
1175
- rowDic = {
1176
- _q2(Cx, i, n, Ni, Nn): -1,
1177
- _q3(Cw, i, 1, n, Ni, Nj, Nn): -1,
1178
- _q3(Cb, i, 1, n, Ni, Nj, Nn + 1): 1,
1179
- }
1180
- A.addNewRow(rowDic, zero, inf)
1181
-
1182
- # Constraints depending on objective function.
1183
- if objective == "maxSpending":
1184
- # Impose optional constraint on final bequest requested in today's $.
1185
- if "bequest" in options:
1186
- bequest = options["bequest"]
1187
- assert isinstance(bequest, (int, float)), "Desired bequest is not a number."
1188
- bequest *= units * self.gamma_n[-1]
1189
- else:
1190
- # If not specified, defaults to $1 (nominal $).
1191
- bequest = 1
1192
-
1193
- row = A.newRow()
1194
- for i in range(Ni):
1195
- row.addElem(_q3(Cb, i, 0, Nn, Ni, Nj, Nn + 1), 1)
1196
- row.addElem(_q3(Cb, i, 1, Nn, Ni, Nj, Nn + 1), 1 - self.nu)
1197
- # Nudge could be added (e.g. 1.02) to artificially favor tax-exempt account
1198
- # as heirs's benefits of 10y tax-free is not weighted in?
1199
- row.addElem(_q3(Cb, i, 2, Nn, Ni, Nj, Nn + 1), 1)
1200
- A.addRow(row, bequest, bequest)
1201
- # self.mylog.vprint('Adding bequest constraint of:', u.d(bequest))
1202
- elif objective == "maxBequest":
1203
- spending = options["netSpending"]
1204
- assert isinstance(spending, (int, float)), "Desired spending provided is not a number."
1205
- # Account for time elapsed in the current year.
1206
- spending *= units * self.yearFracLeft
1207
- # self.mylog.vprint('Maximizing bequest with desired net spending of:', u.d(spending))
1208
- # To allow slack in first year, Cg can be made Nn+1 and store basis in g[Nn].
1209
- A.addNewRow({_q1(Cg, 0, Nn): 1}, spending, spending)
1210
-
1211
1225
  # Set initial balances through constraints.
1212
1226
  for i in range(Ni):
1213
1227
  for j in range(Nj):
1214
1228
  rhs = self.beta_ij[i, j]
1215
1229
  A.addNewRow({_q3(Cb, i, j, 0, Ni, Nj, Nn + 1): 1}, rhs, rhs)
1216
1230
 
1217
- # Link surplus and taxable account deposits regardless of Ni.
1231
+ # Link cash flow surplus and taxable account deposits even for Ni=1.
1218
1232
  for i in range(Ni):
1219
1233
  fac1 = u.krond(i, 0) * (1 - self.eta) + u.krond(i, 1) * self.eta
1220
1234
  for n in range(n_d):
@@ -1228,63 +1242,51 @@ class Plan(object):
1228
1242
  # No surplus allowed during the last year to be used as a tax loophole.
1229
1243
  B.set0_Ub(_q1(Cs, Nn - 1, Nn), zero)
1230
1244
 
1231
- if Ni == 2:
1232
- # No conversion during last year.
1233
- # B.set0_Ub(_q2(Cx, i_d, nd-1, Ni, Nn), zero)
1234
- # B.set0_Ub(_q2(Cx, i_s, Nn-1, Ni, Nn), zero)
1235
-
1236
- # No withdrawals or deposits for any i_d-owned accounts after year of passing.
1237
- # Implicit n_d < Nn imposed by for loop.
1238
- for n in range(n_d, Nn):
1239
- B.set0_Ub(_q2(Cd, i_d, n, Ni, Nn), zero)
1240
- B.set0_Ub(_q2(Cx, i_d, n, Ni, Nn), zero)
1241
- for j in range(Nj):
1242
- B.set0_Ub(_q3(Cw, i_d, j, n, Ni, Nj, Nn), zero)
1243
-
1244
1245
  # Account balances carried from year to year.
1245
1246
  # Considering spousal asset transfer at passing of a spouse.
1246
1247
  # Using hybrid approach with 'if' statement and Kronecker deltas.
1247
1248
  for i in range(Ni):
1248
1249
  for j in range(Nj):
1249
1250
  for n in range(Nn):
1250
- if Ni == 2 and n_d < Nn and i == i_d and n == n_d - 1:
1251
- # fac1 = 1 - (u.krond(n, n_d - 1) * u.krond(i, i_d))
1252
- fac1 = 0
1253
- else:
1254
- fac1 = 1
1255
-
1251
+ # fac1 = 1 - (u.krond(n, n_d - 1) * u.krond(i, i_d))
1252
+ fac1 = 0 if (Ni == 2 and n_d < Nn and i == i_d and n == n_d - 1) else 1
1253
+ fac1_ijn = fac1 * Tau1_ijn[i, j, n]
1256
1254
  rhs = fac1 * self.kappa_ijn[i, j, n] * Tauh_ijn[i, j, n]
1257
1255
 
1258
1256
  row = A.newRow()
1259
1257
  row.addElem(_q3(Cb, i, j, n + 1, Ni, Nj, Nn + 1), 1)
1260
- row.addElem(_q3(Cb, i, j, n, Ni, Nj, Nn + 1), -fac1 * Tau1_ijn[i, j, n])
1261
- row.addElem(_q3(Cw, i, j, n, Ni, Nj, Nn), fac1 * Tau1_ijn[i, j, n])
1262
- 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)
1263
1261
  row.addElem(
1264
1262
  _q2(Cx, i, n, Ni, Nn),
1265
- -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,
1266
1264
  )
1267
1265
 
1268
1266
  if Ni == 2 and n_d < Nn and i == i_s and n == n_d - 1:
1269
1267
  fac2 = self.phi_j[j]
1268
+ fac2_idjn = fac2 * Tau1_ijn[i_d, j, n]
1270
1269
  rhs += fac2 * self.kappa_ijn[i_d, j, n] * Tauh_ijn[i_d, j, n]
1271
- row.addElem(_q3(Cb, i_d, j, n, Ni, Nj, Nn + 1), -fac2 * Tau1_ijn[i_d, j, n])
1272
- row.addElem(_q3(Cw, i_d, j, n, Ni, Nj, Nn), fac2 * Tau1_ijn[i_d, j, n])
1273
- 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)
1274
1274
  row.addElem(
1275
1275
  _q2(Cx, i_d, n, Ni, Nn),
1276
- -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,
1277
1277
  )
1278
1278
  A.addRow(row, rhs, rhs)
1279
1279
 
1280
1280
  tau_0prev = np.roll(self.tau_kn[0, :], 1)
1281
+ # No tax on losses, nor tax-loss harvesting.
1281
1282
  tau_0prev[tau_0prev < 0] = 0
1282
1283
 
1283
- # Net cash flow.
1284
+ # Cash flow for net spending.
1284
1285
  for n in range(Nn):
1285
- rhs = -self.M_n[n]
1286
- row = A.newRow({_q1(Cg, n, Nn): 1})
1287
- 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)
1288
1290
  for i in range(Ni):
1289
1291
  fac = self.psi * self.alpha_ijkn[i, 0, 0, n]
1290
1292
  rhs += (
@@ -1296,13 +1298,13 @@ class Plan(object):
1296
1298
  )
1297
1299
 
1298
1300
  row.addElem(_q3(Cb, i, 0, n, Ni, Nj, Nn + 1), fac * self.mu)
1301
+ row.addElem(_q2(Cd, i, n, Ni, Nn), 1 + fac * self.mu)
1299
1302
  # Minus capital gains on taxable withdrawals using last year's rate if >=0.
1300
1303
  # Plus taxable account withdrawals, and all other withdrawals.
1301
- row.addElem(_q3(Cw, i, 0, n, Ni, Nj, Nn), fac * (tau_0prev[n] - self.mu) - 1)
1304
+ row.addElem(_q3(Cw, i, 0, n, Ni, Nj, Nn), -1 + fac * (tau_0prev[n] - self.mu))
1302
1305
  penalty = 0.1 if n < self.n59[i] else 0
1303
1306
  row.addElem(_q3(Cw, i, 1, n, Ni, Nj, Nn), -1 + penalty)
1304
1307
  row.addElem(_q3(Cw, i, 2, n, Ni, Nj, Nn), -1 + penalty)
1305
- row.addElem(_q2(Cd, i, n, Ni, Nn), fac * self.mu)
1306
1308
 
1307
1309
  # Minus tax on ordinary income, T_n.
1308
1310
  for t in range(Nt):
@@ -1310,7 +1312,7 @@ class Plan(object):
1310
1312
 
1311
1313
  A.addRow(row, rhs, rhs)
1312
1314
 
1313
- # Impose income profile.
1315
+ # Enforce income profile.
1314
1316
  for n in range(1, Nn):
1315
1317
  rowDic = {_q1(Cg, 0, Nn): -spLo * self.xiBar_n[n], _q1(Cg, n, Nn): self.xiBar_n[0]}
1316
1318
  A.addNewRow(rowDic, zero, inf)
@@ -1329,33 +1331,102 @@ class Plan(object):
1329
1331
  row.addElem(_q2(Cx, i, n, Ni, Nn), -1)
1330
1332
 
1331
1333
  # Taxable returns on securities in taxable account.
1332
- fak = np.sum(self.tau_kn[1:Nk, n] * self.alpha_ijkn[i, 0, 1:Nk, n], axis=0)
1333
- rhs += 0.5 * fak * self.kappa_ijn[i, 0, n]
1334
- row.addElem(_q3(Cb, i, 0, n, Ni, Nj, Nn + 1), -fak)
1335
- row.addElem(_q3(Cw, i, 0, n, Ni, Nj, Nn), fak)
1336
- 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)
1337
1339
 
1338
1340
  for t in range(Nt):
1339
1341
  row.addElem(_q2(CF, t, n, Nt, Nn), 1)
1340
1342
 
1341
1343
  A.addRow(row, rhs, rhs)
1342
1344
 
1343
- # 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.
1344
1414
  for i in range(Ni):
1345
1415
  for n in range(self.horizons[i]):
1416
+ # Configure binary variables.
1346
1417
  for z in range(Nz):
1347
- B.setBinary(_q3(Cz, i, n, z, Ni, Nn, Nz))
1418
+ B.setBinary(_q3(Czx, i, n, z, Ni, Nn, Nz))
1348
1419
 
1349
1420
  # Exclude simultaneous deposits and withdrawals from taxable or tax-free accounts.
1350
1421
  A.addNewRow(
1351
- {_q3(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},
1352
1423
  zero,
1353
1424
  bigM,
1354
1425
  )
1355
1426
 
1356
1427
  A.addNewRow(
1357
1428
  {
1358
- _q3(Cz, i, n, 0, Ni, Nn, Nz): bigM,
1429
+ _q3(Czx, i, n, 0, Ni, Nn, Nz): bigM,
1359
1430
  _q3(Cw, i, 0, n, Ni, Nj, Nn): 1,
1360
1431
  _q3(Cw, i, 2, n, Ni, Nj, Nn): 1,
1361
1432
  },
@@ -1365,13 +1436,13 @@ class Plan(object):
1365
1436
 
1366
1437
  # Exclude simultaneous Roth conversions and tax-exempt withdrawals.
1367
1438
  A.addNewRow(
1368
- {_q3(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},
1369
1440
  zero,
1370
1441
  bigM,
1371
1442
  )
1372
1443
 
1373
1444
  A.addNewRow(
1374
- {_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},
1375
1446
  zero,
1376
1447
  bigM,
1377
1448
  )
@@ -1663,14 +1734,14 @@ class Plan(object):
1663
1734
  if objective == "maxSpending" and "bequest" not in myoptions:
1664
1735
  self.mylog.vprint("Using bequest of $1.")
1665
1736
 
1666
- self.prevMAGI = np.zeros(3)
1737
+ self.prevMAGIs = np.zeros(3)
1667
1738
  if "previousMAGIs" in myoptions:
1668
1739
  magi = myoptions["previousMAGIs"]
1669
1740
  if len(magi) != 3:
1670
1741
  raise ValueError("previousMAGIs must have 3 values.")
1671
1742
 
1672
1743
  units = u.getUnits(options.get("units", "k"))
1673
- self.prevMAGI = units * np.array(magi)
1744
+ self.prevMAGIs = units * np.array(magi)
1674
1745
 
1675
1746
  self.lambdha = 0
1676
1747
  if "spendingSlack" in myoptions:
@@ -1704,76 +1775,31 @@ class Plan(object):
1704
1775
  """
1705
1776
  from scipy import optimize
1706
1777
 
1707
- withMedicare = True
1708
- if "withMedicare" in options and options["withMedicare"] is False:
1709
- withMedicare = False
1710
-
1711
- if objective == "maxSpending":
1712
- objFac = -1 / self.xi_n[0]
1713
- else:
1714
- objFac = -1 / self.gamma_n[-1]
1715
-
1716
1778
  # mip_rel_gap smaller than 1e-6 can lead to oscillatory solutions.
1717
1779
  milpOptions = {"disp": False, "mip_rel_gap": 1e-7}
1718
1780
 
1719
- it = 0
1720
- absdiff = np.inf
1721
- old_x = np.zeros(self.nvars)
1722
- old_solutions = [np.inf]
1723
- self._estimateMedicare(None, withMedicare)
1724
- while True:
1725
- self._buildConstraints(objective, options)
1726
- Alu, lbvec, ubvec = self.A.arrays()
1727
- Lb, Ub = self.B.arrays()
1728
- integrality = self.B.integralityArray()
1729
- c = self.c.arrays()
1730
-
1731
- bounds = optimize.Bounds(Lb, Ub)
1732
- constraint = optimize.LinearConstraint(Alu, lbvec, ubvec)
1733
- solution = optimize.milp(
1734
- c,
1735
- integrality=integrality,
1736
- constraints=constraint,
1737
- bounds=bounds,
1738
- options=milpOptions,
1739
- )
1740
- it += 1
1741
-
1742
- if not solution.success:
1743
- break
1744
-
1745
- if not withMedicare:
1746
- break
1747
-
1748
- self._estimateMedicare(solution.x)
1781
+ self._buildConstraints(objective, options)
1782
+ Alu, lbvec, ubvec = self.A.arrays()
1783
+ Lb, Ub = self.B.arrays()
1784
+ integrality = self.B.integralityArray()
1785
+ c = self.c.arrays()
1749
1786
 
1750
- self.mylog.vprint(f"Iteration: {it} objective: {u.d(solution.fun * objFac, f=2)}")
1751
-
1752
- delta = solution.x - old_x
1753
- absdiff = np.sum(np.abs(delta), axis=0)
1754
- if absdiff < 1:
1755
- self.mylog.vprint("Converged on full solution.")
1756
- break
1757
-
1758
- # Avoid oscillatory solutions. Look only at most recent solutions. Within $10.
1759
- isclosenough = abs(-solution.fun - min(old_solutions[int(it / 2) :])) < 10 * self.xi_n[0]
1760
- if isclosenough:
1761
- self.mylog.vprint("Converged through selecting minimum oscillating objective.")
1762
- break
1763
-
1764
- if it > 59:
1765
- self.mylog.vprint("WARNING: Exiting loop on maximum iterations.")
1766
- break
1767
-
1768
- old_solutions.append(-solution.fun)
1769
- old_x = solution.x
1787
+ bounds = optimize.Bounds(Lb, Ub)
1788
+ constraint = optimize.LinearConstraint(Alu, lbvec, ubvec)
1789
+ solution = optimize.milp(c, integrality=integrality,
1790
+ constraints=constraint, bounds=bounds, options=milpOptions)
1770
1791
 
1771
1792
  if solution.success:
1772
- self.mylog.vprint(f"Self-consistent Medicare loop returned after {it} iterations.")
1793
+ self.mylog.vprint("Solution successful.")
1773
1794
  self.mylog.vprint(solution.message)
1795
+ if objective == "maxSpending":
1796
+ objFac = -1 / self.xi_n[0]
1797
+ else:
1798
+ objFac = -1 / self.gamma_n[-1]
1799
+
1774
1800
  self.mylog.vprint(f"Objective: {u.d(solution.fun * objFac)}")
1775
1801
  # self.mylog.vprint('Upper bound:', u.d(-solution.mip_dual_bound))
1776
- self._aggregateResults(solution.x)
1802
+ self._aggregateResults(options, solution.x)
1777
1803
  self._timestamp = datetime.now().strftime("%Y-%m-%d at %H:%M:%S")
1778
1804
  self.caseStatus = "solved"
1779
1805
  else:
@@ -1788,17 +1814,6 @@ class Plan(object):
1788
1814
  """
1789
1815
  import mosek
1790
1816
 
1791
- withMedicare = True
1792
- if "withMedicare" in options and options["withMedicare"] is False:
1793
- withMedicare = False
1794
-
1795
- if objective == "maxSpending":
1796
- objFac = -1 / self.xi_n[0]
1797
- else:
1798
- objFac = -1 / self.gamma_n[-1]
1799
-
1800
- # mip_rel_gap smaller than 1e-6 can lead to oscillatory solutions.
1801
-
1802
1817
  bdic = {
1803
1818
  "fx": mosek.boundkey.fx,
1804
1819
  "fr": mosek.boundkey.fr,
@@ -1807,88 +1822,57 @@ class Plan(object):
1807
1822
  "up": mosek.boundkey.up,
1808
1823
  }
1809
1824
 
1810
- it = 0
1811
- absdiff = np.inf
1812
- old_x = np.zeros(self.nvars)
1813
- old_solutions = [np.inf]
1814
- self._estimateMedicare(None, withMedicare)
1815
- while True:
1816
- self._buildConstraints(objective, options)
1817
- Aind, Aval, clb, cub = self.A.lists()
1818
- ckeys = self.A.keys()
1819
- vlb, vub = self.B.arrays()
1820
- integrality = self.B.integralityList()
1821
- vkeys = self.B.keys()
1822
- cind, cval = self.c.lists()
1823
-
1824
- task = mosek.Task()
1825
- # task.putdouparam(mosek.dparam.mio_rel_gap_const, 1e-5)
1826
- # task.putdouparam(mosek.dparam.mio_tol_abs_relax_int, 1e-4)
1827
- # task.set_Stream(mosek.streamtype.msg, _streamPrinter)
1828
- task.appendcons(self.A.ncons)
1829
- task.appendvars(self.A.nvars)
1830
-
1831
- for ii in range(len(cind)):
1832
- task.putcj(cind[ii], cval[ii])
1833
-
1834
- for ii in range(self.nvars):
1835
- task.putvarbound(ii, bdic[vkeys[ii]], vlb[ii], vub[ii])
1836
-
1837
- for ii in range(len(integrality)):
1838
- task.putvartype(integrality[ii], mosek.variabletype.type_int)
1839
-
1840
- for ii in range(self.A.ncons):
1841
- task.putarow(ii, Aind[ii], Aval[ii])
1842
- task.putconbound(ii, bdic[ckeys[ii]], clb[ii], cub[ii])
1843
-
1844
- task.putobjsense(mosek.objsense.minimize)
1845
- task.optimize()
1846
-
1847
- solsta = task.getsolsta(mosek.soltype.itg)
1848
- # prosta = task.getprosta(mosek.soltype.itg)
1849
- it += 1
1850
-
1851
- if solsta != mosek.solsta.integer_optimal:
1852
- break
1825
+ self._buildConstraints(objective, options)
1826
+ Aind, Aval, clb, cub = self.A.lists()
1827
+ ckeys = self.A.keys()
1828
+ vlb, vub = self.B.arrays()
1829
+ integrality = self.B.integralityList()
1830
+ vkeys = self.B.keys()
1831
+ cind, cval = self.c.lists()
1853
1832
 
1854
- xx = np.array(task.getxx(mosek.soltype.itg))
1855
- 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)
1856
1839
 
1857
- if withMedicare is False:
1858
- break
1859
-
1860
- self._estimateMedicare(xx)
1840
+ for ii in range(len(cind)):
1841
+ task.putcj(cind[ii], cval[ii])
1861
1842
 
1862
- self.mylog.vprint("Iteration:", it, "objective:", u.d(solution * objFac, f=2))
1843
+ for ii in range(self.nvars):
1844
+ task.putvarbound(ii, bdic[vkeys[ii]], vlb[ii], vub[ii])
1863
1845
 
1864
- delta = xx - old_x
1865
- absdiff = np.sum(np.abs(delta), axis=0)
1866
- if absdiff < 1:
1867
- self.mylog.vprint("Converged on full solution.")
1868
- break
1846
+ for ii in range(len(integrality)):
1847
+ task.putvartype(integrality[ii], mosek.variabletype.type_int)
1869
1848
 
1870
- # Avoid oscillatory solutions. Look only at most recent solutions. Within $10.
1871
- isclosenough = abs(-solution - min(old_solutions[int(it / 2) :])) < 10 * self.xi_n[0]
1872
- if isclosenough:
1873
- self.mylog.vprint("Converged through selecting minimum oscillating objective.")
1874
- break
1849
+ for ii in range(self.A.ncons):
1850
+ task.putarow(ii, Aind[ii], Aval[ii])
1851
+ task.putconbound(ii, bdic[ckeys[ii]], clb[ii], cub[ii])
1875
1852
 
1876
- if it > 59:
1877
- self.mylog.vprint("WARNING: Exiting loop on maximum iterations.")
1878
- break
1853
+ task.putobjsense(mosek.objsense.minimize)
1854
+ task.optimize()
1879
1855
 
1880
- old_solutions.append(-solution)
1881
- old_x = xx
1856
+ solsta = task.getsolsta(mosek.soltype.itg)
1857
+ # prosta = task.getprosta(mosek.soltype.itg)
1882
1858
 
1883
1859
  task.set_Stream(mosek.streamtype.wrn, _streamPrinter)
1884
1860
  # task.writedata(self._name+'.ptf')
1885
1861
  if solsta == mosek.solsta.integer_optimal:
1886
- 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.")
1887
1866
  task.solutionsummary(mosek.streamtype.msg)
1867
+ if objective == "maxSpending":
1868
+ objFac = -1 / self.xi_n[0]
1869
+ else:
1870
+ objFac = -1 / self.gamma_n[-1]
1871
+
1888
1872
  self.mylog.vprint("Objective:", u.d(solution * objFac))
1889
1873
  self.caseStatus = "solved"
1890
1874
  # self.mylog.vprint('Upper bound:', u.d(-solution.mip_dual_bound))
1891
- self._aggregateResults(xx)
1875
+ self._aggregateResults(options, xx)
1892
1876
  self._timestamp = datetime.now().strftime("%Y/%m/%d %H:%M:%S")
1893
1877
  else:
1894
1878
  self.mylog.vprint("WARNING: Optimization failed:", "Infeasible or unbounded.")
@@ -1912,11 +1896,11 @@ class Plan(object):
1912
1896
  self.F_tn = self.F_tn.reshape((self.N_t, self.N_n))
1913
1897
  MAGI_n = np.sum(self.F_tn, axis=0) + np.array(x[self.C["e"] : self.C["F"]])
1914
1898
 
1915
- self.M_n = tx.mediCosts(self.yobs, self.horizons, MAGI_n, self.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)
1916
1900
 
1917
1901
  return None
1918
1902
 
1919
- def _aggregateResults(self, x):
1903
+ def _aggregateResults(self, options, x):
1920
1904
  """
1921
1905
  Utility function to aggregate results from solver.
1922
1906
  Process all results from solution vector.
@@ -1926,6 +1910,7 @@ class Plan(object):
1926
1910
  Nj = self.N_j
1927
1911
  Nk = self.N_k
1928
1912
  Nn = self.N_n
1913
+ Nq = self.N_q
1929
1914
  Nt = self.N_t
1930
1915
  # Nz = self.N_z
1931
1916
  n_d = self.n_d
@@ -1935,10 +1920,12 @@ class Plan(object):
1935
1920
  Ce = self.C["e"]
1936
1921
  CF = self.C["F"]
1937
1922
  Cg = self.C["g"]
1923
+ Cm = self.C["m"]
1938
1924
  Cs = self.C["s"]
1939
1925
  Cw = self.C["w"]
1940
1926
  Cx = self.C["x"]
1941
- Cz = self.C["z"]
1927
+ Czx = self.C["zx"]
1928
+ Czm = self.C["zm"]
1942
1929
 
1943
1930
  x = u.roundCents(x)
1944
1931
 
@@ -1957,19 +1944,28 @@ class Plan(object):
1957
1944
  self.F_tn = np.array(x[CF:Cg])
1958
1945
  self.F_tn = self.F_tn.reshape((Nt, Nn))
1959
1946
 
1960
- self.g_n = np.array(x[Cg: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)
1961
1953
 
1962
1954
  self.s_n = np.array(x[Cs:Cw])
1963
1955
 
1964
1956
  self.w_ijn = np.array(x[Cw:Cx])
1965
1957
  self.w_ijn = self.w_ijn.reshape((Ni, Nj, Nn))
1966
1958
 
1967
- self.x_in = np.array(x[Cx:Cz])
1959
+ self.x_in = np.array(x[Cx:Czx])
1968
1960
  self.x_in = self.x_in.reshape((Ni, Nn))
1969
1961
 
1970
- # self.z_inz = np.array(x[Cz:])
1971
- # self.z_inz = self.z_inz.reshape((Ni, Nn, Nz))
1972
- # 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))
1973
1969
 
1974
1970
  # Partial distribution at the passing of first spouse.
1975
1971
  if Ni == 2 and n_d < Nn:
@@ -2158,10 +2154,10 @@ class Plan(object):
2158
2154
  dic["Total tax paid on gains and dividends"] = f"{u.d(taxPaidNow)}"
2159
2155
  dic["[Total tax paid on gains and dividends]"] = f"{u.d(taxPaid)}"
2160
2156
 
2161
- taxPaid = np.sum(self.M_n, axis=0)
2162
- taxPaidNow = np.sum(self.M_n / self.gamma_n[:-1], axis=0)
2163
- dic["Total Medicare premiums paid"] = f"{u.d(taxPaidNow)}"
2164
- 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)}"
2165
2161
 
2166
2162
  if self.N_i == 2 and self.n_d < self.N_n:
2167
2163
  p_j = self.partialEstate_j * (1 - self.phi_j)
@@ -2614,12 +2610,12 @@ class Plan(object):
2614
2610
  style = {"income taxes": "-", "Medicare": "-."}
2615
2611
 
2616
2612
  if value == "nominal":
2617
- series = {"income taxes": self.T_n, "Medicare": self.M_n}
2613
+ series = {"income taxes": self.T_n, "Medicare": self.m_n}
2618
2614
  yformat = "\\$k (nominal)"
2619
2615
  else:
2620
2616
  series = {
2621
2617
  "income taxes": self.T_n / self.gamma_n[:-1],
2622
- "Medicare": self.M_n / self.gamma_n[:-1],
2618
+ "Medicare": self.m_n / self.gamma_n[:-1],
2623
2619
  }
2624
2620
  yformat = "\\$k (" + str(self.year_n[0]) + "\\$)"
2625
2621
 
@@ -2754,7 +2750,7 @@ class Plan(object):
2754
2750
  "net spending": self.g_n,
2755
2751
  "taxable ord. income": self.G_n,
2756
2752
  "taxable gains/divs": self.Q_n,
2757
- "Tax bills + Med.": self.T_n + self.U_n + self.M_n,
2753
+ "Tax bills + Med.": self.T_n + self.U_n + self.m_n,
2758
2754
  }
2759
2755
 
2760
2756
  fillsheet(ws, incomeDic, "currency")
@@ -2770,7 +2766,7 @@ class Plan(object):
2770
2766
  "all deposits": -np.sum(self.d_in, axis=0),
2771
2767
  "ord taxes": -self.T_n,
2772
2768
  "div taxes": -self.U_n,
2773
- "Medicare": -self.M_n,
2769
+ "Medicare": -self.m_n,
2774
2770
  }
2775
2771
  sname = "Cash Flow"
2776
2772
  ws = wb.create_sheet(sname)
owlplanner/tax2025.py CHANGED
@@ -42,14 +42,15 @@ taxBrackets_TCJA = np.array(
42
42
 
43
43
  irmaaBrackets = np.array(
44
44
  [
45
- [0, 106000, 133000, 167000, 200000, 500000],
46
- [0, 212000, 266000, 334000, 400000, 750000],
45
+ [0, 106000, 133000, 167000, 200000, 500000, 9999999],
46
+ [0, 212000, 266000, 334000, 400000, 750000, 9999999],
47
47
  ]
48
48
  )
49
49
 
50
50
  # Index [0] stores the standard Medicare part B premium.
51
51
  # Following values are incremental IRMAA part B monthly fees.
52
52
  irmaaFees = 12 * np.array([185.00, 74.00, 111.00, 110.90, 111.00, 37.00])
53
+ irmaaCosts = np.cumsum(irmaaFees)
53
54
 
54
55
  # Make projection for non-TCJA using 2017 to current year.
55
56
  # taxBrackets_2017 = np.array(
@@ -82,6 +83,34 @@ extra65Deduction = np.array([2000, 1600])
82
83
  ###############################################################################
83
84
 
84
85
 
86
+ def mediVals(yobs, horizons, gamma_n, Nn, Nq):
87
+ """
88
+ Return tuple (nm, L, C) of year index when Medicare starts and vectors L, and C
89
+ defining end points of constant piecewise linear functions representing IRMAA fees.
90
+ """
91
+ thisyear = date.today().year
92
+ assert Nq == len(irmaaCosts), f"Inconsistent value of Nq: {Nq}."
93
+ assert Nq+1 == len(irmaaBrackets[0]), "Inconsistent IRMAA brackets array."
94
+ Ni = len(yobs)
95
+ L = np.zeros((Nn, Nq+1))
96
+ C = np.zeros((Nn, Nq))
97
+ nm = 0
98
+ for n in range(Nn):
99
+ icount = 0
100
+ if thisyear + n - yobs[0] >= 65 and n < horizons[0]:
101
+ icount += 1
102
+ if Ni == 2 and thisyear + n - yobs[1] >= 65 and n < horizons[1]:
103
+ icount += 1
104
+ if icount > 0:
105
+ status = 0 if Ni == 1 else 1 if n < horizons[0] and n < horizons[1] else 0
106
+ L[n] = gamma_n[n] * irmaaBrackets[status]
107
+ C[n] = icount * gamma_n[n] * irmaaCosts
108
+ else:
109
+ nm = n + 1
110
+
111
+ return nm, L, C
112
+
113
+
85
114
  def mediCosts(yobs, horizons, magi, prevmagi, gamma_n, Nn):
86
115
  """
87
116
  Compute Medicare costs directly.
owlplanner/version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "2025.04.26"
1
+ __version__ = "2025.04.28"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: owlplanner
3
- Version: 2025.4.26
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,17 +1,17 @@
1
1
  owlplanner/__init__.py,sha256=QqrdT0Qks20osBTg7h0vJHAxpP9lL7DA99xb0nYbtw4,254
2
- owlplanner/abcapi.py,sha256=LbzW_KcNy0IeHp42MUHwGu_H67B2h_e1_vu-c2ACTkQ,6646
2
+ owlplanner/abcapi.py,sha256=xf5233Ph952y7O-m1vc0WmbTz3RlQtDemfqD40edlZ4,6710
3
3
  owlplanner/config.py,sha256=F6GS3n02VeFX0GCVeM4J7Ra0in4N632W6TZIXk7Yj2w,12519
4
4
  owlplanner/logging.py,sha256=tYMw04O-XYSzjTj36fmKJGLcE1VkK6k6oJNeqtKXzuc,2530
5
- owlplanner/plan.py,sha256=X__wXxR0teB8RfkfV1BMgo_F_uxNYEBWsVnYHJtifW0,117550
5
+ owlplanner/plan.py,sha256=_9MQWUEcElLbdZ-J0DqmcMim3Q9KrVmUpFPHtSiatZA,118775
6
6
  owlplanner/progress.py,sha256=8jlCvvtgDI89zXVNMBg1-lnEyhpPvKQS2X5oAIpoOVQ,384
7
7
  owlplanner/rates.py,sha256=gJaoe-gJqWCQV5qVLlHp-Yn9TSJs-PJzeTbOwMCbqWs,15682
8
- owlplanner/tax2025.py,sha256=JDBtFFAf2bWtKUMuE3W5F0nBhYaKBjmdJj0iayM2iGA,7829
8
+ owlplanner/tax2025.py,sha256=yxpFhETe2fmo1qosKSrIX81z4fMMu4VcKSBd-ePSrTw,8913
9
9
  owlplanner/timelists.py,sha256=tYieZU67FT6TCcQQis36JaXGI7dT6NqD7RvdEjgJL4M,4026
10
10
  owlplanner/utils.py,sha256=WpJgn79YZfH8UCkcmhd-AZlxlGuz1i1-UDBRXImsY6I,2485
11
- owlplanner/version.py,sha256=UZqcSzscXDdd_zdDICloqhxdi1jWHPfAWPlpO1-9dSA,28
11
+ owlplanner/version.py,sha256=vGljB4UJ5r4pxBlvSDiQ77nNw16bav9YeZM-RB6mrPo,28
12
12
  owlplanner/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
13
  owlplanner/data/rates.csv,sha256=6fxg56BVVORrj9wJlUGFdGXKvOX5r7CSca8uhUbbuIU,3734
14
- owlplanner-2025.4.26.dist-info/METADATA,sha256=3jihkQ5yNqbYpnyzZpImLUwYsyf9KnDu0Rn8HIewhu8,53927
15
- owlplanner-2025.4.26.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
16
- owlplanner-2025.4.26.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
17
- owlplanner-2025.4.26.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,,