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 +17 -23
- owlplanner/plan.py +325 -298
- owlplanner/tax2025.py +2 -31
- owlplanner/version.py +1 -1
- {owlplanner-2025.4.28.dist-info → owlplanner-2025.5.2.dist-info}/METADATA +23 -27
- {owlplanner-2025.4.28.dist-info → owlplanner-2025.5.2.dist-info}/RECORD +8 -8
- {owlplanner-2025.4.28.dist-info → owlplanner-2025.5.2.dist-info}/WHEEL +0 -0
- {owlplanner-2025.4.28.dist-info → owlplanner-2025.5.2.dist-info}/licenses/LICENSE +0 -0
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=
|
|
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=
|
|
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("
|
|
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,
|
|
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
|
|
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
|
|
309
|
-
self.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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["
|
|
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["
|
|
1044
|
-
|
|
1045
|
-
self.
|
|
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(
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
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
|
|
1117
|
+
# Income tax bracket range inequalities.
|
|
1148
1118
|
for t in range(Nt):
|
|
1149
1119
|
for n in range(Nn):
|
|
1150
|
-
B.
|
|
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.
|
|
1124
|
+
B.setRange(_q1(Ce, n, Nn), zero, self.sigmaBar_n[n])
|
|
1155
1125
|
|
|
1156
|
-
#
|
|
1126
|
+
# Start with no activities after passing.
|
|
1157
1127
|
for i in range(Ni):
|
|
1158
|
-
for
|
|
1159
|
-
|
|
1160
|
-
|
|
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.
|
|
1132
|
+
B.setRange(_q3(Cw, i, j, n, Ni, Nj, Nn), zero, zero)
|
|
1178
1133
|
|
|
1179
|
-
# Roth conversions
|
|
1180
|
-
#
|
|
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
|
-
#
|
|
1200
|
-
|
|
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.
|
|
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.
|
|
1179
|
+
B.setRange(_q2(Cx, i_x, n, Ni, Nn), zero, zero)
|
|
1224
1180
|
|
|
1225
|
-
#
|
|
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
|
|
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.
|
|
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
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
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), -
|
|
1259
|
-
row.addElem(
|
|
1260
|
-
row.addElem(
|
|
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)) *
|
|
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(
|
|
1272
|
-
row.addElem(_q2(Cd, i_d, n, Ni, Nn), -u.krond(j, 0) *
|
|
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)) *
|
|
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
|
-
#
|
|
1301
|
+
# Net cash flow.
|
|
1285
1302
|
for n in range(Nn):
|
|
1286
|
-
rhs =
|
|
1287
|
-
row = A.newRow()
|
|
1288
|
-
row.addElem(_q1(
|
|
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),
|
|
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
|
-
#
|
|
1331
|
+
# Impose income profile.
|
|
1316
1332
|
for n in range(1, Nn):
|
|
1317
|
-
rowDic = {_q1(Cg, 0, Nn):
|
|
1318
|
-
A.addNewRow(rowDic,
|
|
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
|
-
|
|
1335
|
-
rhs += 0.5 *
|
|
1336
|
-
row.addElem(_q3(Cb, i, 0, n, Ni, Nj, Nn + 1), -
|
|
1337
|
-
row.addElem(_q3(Cw, i, 0, n, Ni, Nj, Nn),
|
|
1338
|
-
row.addElem(_q2(Cd, i, n, Ni, Nn), -
|
|
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
|
-
#
|
|
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
|
-
#
|
|
1417
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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=
|
|
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
|
|
1711
|
-
|
|
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.
|
|
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.
|
|
1694
|
+
self.prevMAGI = units * np.array(magi)
|
|
1745
1695
|
|
|
1746
|
-
|
|
1747
|
-
if
|
|
1748
|
-
|
|
1749
|
-
|
|
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
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
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
|
|
1708
|
+
solverMethod = self._milpSolve
|
|
1709
|
+
elif solver == "PuLP/CBC":
|
|
1710
|
+
solverMethod = self._pulpSolve
|
|
1764
1711
|
elif solver == "MOSEK":
|
|
1765
|
-
self._mosekSolve
|
|
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(
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
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
|
-
|
|
1841
|
+
raise RuntimeError(f"Internal error: Variable with wierd bound f{vkeys[i]}.")
|
|
1799
1842
|
|
|
1800
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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:
|
|
2000
|
+
self.x_in = np.array(x[Cx:Cz])
|
|
1960
2001
|
self.x_in = self.x_in.reshape((Ni, Nn))
|
|
1961
2002
|
|
|
1962
|
-
# self.
|
|
1963
|
-
# self.
|
|
1964
|
-
# print(self.
|
|
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
|
-
|
|
2158
|
-
|
|
2159
|
-
dic["Total Medicare premiums paid"] = f"{u.d(
|
|
2160
|
-
dic["[Total Medicare premiums paid]"] = f"{u.d(
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
46
|
-
[0, 212000, 266000, 334000, 400000, 750000
|
|
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.
|
|
1
|
+
__version__ = "2025.05.02"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: owlplanner
|
|
3
|
-
Version: 2025.
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
786
|
-
and optimized to suit the goals of the selected objective function
|
|
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
|
-
|
|
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
|
|
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
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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
|
-
|
|
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 ©
|
|
894
|
+
Copyright © 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=
|
|
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=
|
|
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=
|
|
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=
|
|
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.
|
|
15
|
-
owlplanner-2025.
|
|
16
|
-
owlplanner-2025.
|
|
17
|
-
owlplanner-2025.
|
|
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,,
|
|
File without changes
|
|
File without changes
|