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