owlplanner 2025.6.21__py3-none-any.whl → 2025.8.1__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 +1 -1
- owlplanner/config.py +3 -5
- owlplanner/mylogging.py +1 -1
- owlplanner/plan.py +309 -208
- owlplanner/progress.py +1 -1
- owlplanner/rates.py +1 -23
- owlplanner/tax2025.py +120 -41
- owlplanner/timelists.py +3 -2
- owlplanner/utils.py +1 -1
- owlplanner/version.py +1 -1
- {owlplanner-2025.6.21.dist-info → owlplanner-2025.8.1.dist-info}/METADATA +1 -1
- owlplanner-2025.8.1.dist-info/RECORD +22 -0
- owlplanner-2025.6.21.dist-info/RECORD +0 -22
- {owlplanner-2025.6.21.dist-info → owlplanner-2025.8.1.dist-info}/WHEEL +0 -0
- {owlplanner-2025.6.21.dist-info → owlplanner-2025.8.1.dist-info}/licenses/LICENSE +0 -0
owlplanner/plan.py
CHANGED
|
@@ -10,7 +10,7 @@ mathematical model and a description of all variables and parameters.
|
|
|
10
10
|
|
|
11
11
|
Copyright © 2024 - Martin-D. Lacasse
|
|
12
12
|
|
|
13
|
-
Disclaimers: This code is for
|
|
13
|
+
Disclaimers: This code is for educational purposes only and does not constitute financial advice.
|
|
14
14
|
|
|
15
15
|
"""
|
|
16
16
|
|
|
@@ -226,12 +226,13 @@ class Plan(object):
|
|
|
226
226
|
self._name = name
|
|
227
227
|
self.setLogstreams(verbose, logstreams)
|
|
228
228
|
|
|
229
|
-
# 7 tax brackets, 3 types of accounts, 4 classes of assets.
|
|
229
|
+
# 7 tax brackets, 6 Medicare brackets, 3 types of accounts, 4 classes of assets.
|
|
230
230
|
self.N_t = 7
|
|
231
|
+
self.N_q = 6
|
|
231
232
|
self.N_j = 3
|
|
232
233
|
self.N_k = 4
|
|
233
234
|
# 2 binary variables.
|
|
234
|
-
self.
|
|
235
|
+
self.N_zx = 2
|
|
235
236
|
|
|
236
237
|
# Default interpolation parameters for allocation ratios.
|
|
237
238
|
self.interpMethod = "linear"
|
|
@@ -258,8 +259,9 @@ class Plan(object):
|
|
|
258
259
|
raise ValueError("Name for each individual must be provided.")
|
|
259
260
|
|
|
260
261
|
self.filingStatus = ("single", "married")[self.N_i - 1]
|
|
261
|
-
|
|
262
|
-
|
|
262
|
+
|
|
263
|
+
# Default year OBBBA speculated to be expired and replaced by pre-TCJA rates.
|
|
264
|
+
self.yOBBBA = 2032
|
|
263
265
|
self.inames = inames
|
|
264
266
|
self.yobs = np.array(yobs, dtype=np.int32)
|
|
265
267
|
self.expectancy = np.array(expectancy, dtype=np.int32)
|
|
@@ -283,7 +285,7 @@ class Plan(object):
|
|
|
283
285
|
self.i_s = -1
|
|
284
286
|
|
|
285
287
|
# Default parameters:
|
|
286
|
-
self.
|
|
288
|
+
self.psi_n = np.zeros(self.N_n) # Long-term income tax rate on capital gains (decimal)
|
|
287
289
|
self.chi = 0.6 # Survivor fraction
|
|
288
290
|
self.mu = 0.018 # Dividend rate (decimal)
|
|
289
291
|
self.nu = 0.30 # Heirs tax rate (decimal)
|
|
@@ -297,20 +299,22 @@ class Plan(object):
|
|
|
297
299
|
self.zeta_in = np.zeros((self.N_i, self.N_n))
|
|
298
300
|
self.pensionAmounts = np.zeros(self.N_i)
|
|
299
301
|
self.pensionAges = 65 * np.ones(self.N_i, dtype=np.int32)
|
|
300
|
-
self.pensionIsIndexed = [False
|
|
302
|
+
self.pensionIsIndexed = [False] * self.N_i
|
|
301
303
|
self.ssecAmounts = np.zeros(self.N_i)
|
|
302
304
|
self.ssecAges = 67 * np.ones(self.N_i, dtype=np.int32)
|
|
303
305
|
|
|
304
306
|
# Parameters from timeLists initialized to zero.
|
|
305
307
|
self.omega_in = np.zeros((self.N_i, self.N_n))
|
|
306
308
|
self.Lambda_in = np.zeros((self.N_i, self.N_n))
|
|
309
|
+
# Go back 5 years for maturation rules on IRA and Roth.
|
|
307
310
|
self.myRothX_in = np.zeros((self.N_i, self.N_n + 5))
|
|
308
311
|
self.kappa_ijn = np.zeros((self.N_i, self.N_j, self.N_n + 5))
|
|
309
312
|
|
|
310
|
-
# Previous
|
|
313
|
+
# Previous 2 years of MAGI needed for Medicare.
|
|
311
314
|
self.prevMAGI = np.zeros((2))
|
|
315
|
+
self.MAGI_n = np.zeros(self.N_n)
|
|
312
316
|
|
|
313
|
-
# Init
|
|
317
|
+
# Init current balances to none.
|
|
314
318
|
self.beta_ij = None
|
|
315
319
|
self.startDate = None
|
|
316
320
|
|
|
@@ -327,8 +331,6 @@ class Plan(object):
|
|
|
327
331
|
# Prepare RMD time series.
|
|
328
332
|
self.rho_in = tx.rho_in(self.yobs, self.N_n)
|
|
329
333
|
|
|
330
|
-
self._buildOffsetMap()
|
|
331
|
-
|
|
332
334
|
# Initialize guardrails to ensure proper configuration.
|
|
333
335
|
self._adjustedParameters = False
|
|
334
336
|
self.timeListsFileName = "None"
|
|
@@ -340,7 +342,7 @@ class Plan(object):
|
|
|
340
342
|
self.ARCoord = None
|
|
341
343
|
self.objective = "unknown"
|
|
342
344
|
|
|
343
|
-
# Placeholders to check if properly configured.
|
|
345
|
+
# Placeholders values used to check if properly configured.
|
|
344
346
|
self.xi_n = None
|
|
345
347
|
self.alpha_ijkn = None
|
|
346
348
|
|
|
@@ -368,7 +370,7 @@ class Plan(object):
|
|
|
368
370
|
"""
|
|
369
371
|
Set the date when the plan starts in the current year.
|
|
370
372
|
This is mostly for reproducibility purposes and back projecting known balances to Jan 1st.
|
|
371
|
-
String format of mydate is '
|
|
373
|
+
String format of mydate is 'MM/DD', 'MM-DD', 'YYYY-MM-DD', or 'YYYY/MM/DD'. Year is ignored.
|
|
372
374
|
"""
|
|
373
375
|
import calendar
|
|
374
376
|
|
|
@@ -397,7 +399,7 @@ class Plan(object):
|
|
|
397
399
|
|
|
398
400
|
return None
|
|
399
401
|
|
|
400
|
-
def
|
|
402
|
+
def _checkValueType(self, value):
|
|
401
403
|
"""
|
|
402
404
|
Short utility function to parse and check arguments for plotting.
|
|
403
405
|
"""
|
|
@@ -450,7 +452,7 @@ class Plan(object):
|
|
|
450
452
|
Set plots between nominal values or today's $.
|
|
451
453
|
"""
|
|
452
454
|
|
|
453
|
-
self.defaultPlots = self.
|
|
455
|
+
self.defaultPlots = self._checkValueType(value)
|
|
454
456
|
self.mylog.vprint(f"Setting plots default value to {value}.")
|
|
455
457
|
|
|
456
458
|
def setPlotBackend(self, backend: str):
|
|
@@ -463,39 +465,29 @@ class Plan(object):
|
|
|
463
465
|
|
|
464
466
|
if backend != self._plotterName:
|
|
465
467
|
self._plotter = PlotFactory.createBackend(backend)
|
|
468
|
+
self._plotterName = backend
|
|
466
469
|
self.mylog.vprint(f"Setting plotting backend to {backend}.")
|
|
467
470
|
|
|
468
471
|
def setDividendRate(self, mu):
|
|
469
472
|
"""
|
|
470
473
|
Set dividend tax rate. Rate is in percent. Default 1.8%.
|
|
471
474
|
"""
|
|
472
|
-
if not (0 <= mu <=
|
|
473
|
-
raise ValueError("Rate must be between 0 and
|
|
475
|
+
if not (0 <= mu <= 5):
|
|
476
|
+
raise ValueError("Rate must be between 0 and 5.")
|
|
474
477
|
mu /= 100
|
|
475
478
|
self.mylog.vprint(f"Dividend tax rate set to {u.pc(mu, f=0)}.")
|
|
476
479
|
self.mu = mu
|
|
477
480
|
self.caseStatus = "modified"
|
|
478
481
|
|
|
479
|
-
def
|
|
482
|
+
def setExpirationYearOBBBA(self, yOBBBA):
|
|
480
483
|
"""
|
|
481
|
-
Set year at which
|
|
484
|
+
Set year at which OBBBA is speculated to expire and rates go back to something like pre-TCJA.
|
|
482
485
|
"""
|
|
483
|
-
self.mylog.vprint(f"Setting
|
|
484
|
-
self.
|
|
486
|
+
self.mylog.vprint(f"Setting OBBBA expiration year to {yOBBBA}.")
|
|
487
|
+
self.yOBBBA = yOBBBA
|
|
485
488
|
self.caseStatus = "modified"
|
|
486
489
|
self._adjustedParameters = False
|
|
487
490
|
|
|
488
|
-
def setLongTermCapitalTaxRate(self, psi):
|
|
489
|
-
"""
|
|
490
|
-
Set long-term income tax rate. Rate is in percent. Default 15%.
|
|
491
|
-
"""
|
|
492
|
-
if not (0 <= psi <= 100):
|
|
493
|
-
raise ValueError("Rate must be between 0 and 100.")
|
|
494
|
-
psi /= 100
|
|
495
|
-
self.mylog.vprint(f"Long-term capital gain income tax set to {u.pc(psi, f=0)}.")
|
|
496
|
-
self.psi = psi
|
|
497
|
-
self.caseStatus = "modified"
|
|
498
|
-
|
|
499
491
|
def setBeneficiaryFractions(self, phi):
|
|
500
492
|
"""
|
|
501
493
|
Set fractions of savings accounts that is left to surviving spouse.
|
|
@@ -527,7 +519,7 @@ class Plan(object):
|
|
|
527
519
|
self.nu = nu
|
|
528
520
|
self.caseStatus = "modified"
|
|
529
521
|
|
|
530
|
-
def setPension(self, amounts, ages, indexed=
|
|
522
|
+
def setPension(self, amounts, ages, indexed=None, units="k"):
|
|
531
523
|
"""
|
|
532
524
|
Set value of pension for each individual and commencement age.
|
|
533
525
|
Units are in $k, unless specified otherwise: 'k', 'M', or '1'.
|
|
@@ -536,8 +528,8 @@ class Plan(object):
|
|
|
536
528
|
raise ValueError(f"Amounts must have {self.N_i} entries.")
|
|
537
529
|
if len(ages) != self.N_i:
|
|
538
530
|
raise ValueError(f"Ages must have {self.N_i} entries.")
|
|
539
|
-
if
|
|
540
|
-
|
|
531
|
+
if indexed is None:
|
|
532
|
+
indexed = [False] * self.N_i
|
|
541
533
|
|
|
542
534
|
fac = u.getUnits(units)
|
|
543
535
|
amounts = u.rescale(amounts, fac)
|
|
@@ -550,7 +542,7 @@ class Plan(object):
|
|
|
550
542
|
self.pi_in = np.zeros((self.N_i, self.N_n))
|
|
551
543
|
for i in range(self.N_i):
|
|
552
544
|
if amounts[i] != 0:
|
|
553
|
-
ns = max(0,
|
|
545
|
+
ns = max(0, ages[i] - thisyear + self.yobs[i])
|
|
554
546
|
nd = self.horizons[i]
|
|
555
547
|
self.pi_in[i, ns:nd] = amounts[i]
|
|
556
548
|
|
|
@@ -581,13 +573,13 @@ class Plan(object):
|
|
|
581
573
|
thisyear = date.today().year
|
|
582
574
|
self.zeta_in = np.zeros((self.N_i, self.N_n))
|
|
583
575
|
for i in range(self.N_i):
|
|
584
|
-
ns = max(0,
|
|
576
|
+
ns = max(0, ages[i] - thisyear + self.yobs[i])
|
|
585
577
|
nd = self.horizons[i]
|
|
586
578
|
self.zeta_in[i, ns:nd] = amounts[i]
|
|
587
579
|
|
|
588
580
|
if self.N_i == 2:
|
|
589
|
-
# Approximate calculation for spousal benefit (only valid at FRA).
|
|
590
|
-
self.zeta_in[self.i_s, self.n_d :] = max(amounts[self.i_s], amounts[self.i_d])
|
|
581
|
+
# Approximate calculation for spousal benefit (only valid at FRA, and if of similar ages).
|
|
582
|
+
self.zeta_in[self.i_s, self.n_d :] = max(amounts[self.i_s], 0.5*amounts[self.i_d])
|
|
591
583
|
|
|
592
584
|
self.ssecAmounts = np.array(amounts)
|
|
593
585
|
self.ssecAges = np.array(ages, dtype=np.int32)
|
|
@@ -669,36 +661,6 @@ class Plan(object):
|
|
|
669
661
|
corr=self.rateCorr,
|
|
670
662
|
)
|
|
671
663
|
|
|
672
|
-
def value(self, amount, year):
|
|
673
|
-
"""
|
|
674
|
-
Return value of amount deflated or inflated at the beginning
|
|
675
|
-
of the year specified.
|
|
676
|
-
If year is in the past, value is made at the beginning of this year.
|
|
677
|
-
If year is in the future, amount is adjusted from a reference time
|
|
678
|
-
aligned with the beginning of the plan to the beginning of the
|
|
679
|
-
year specified.
|
|
680
|
-
"""
|
|
681
|
-
thisyear = date.today().year
|
|
682
|
-
if year <= thisyear:
|
|
683
|
-
return rates.historicalValue(amount, year)
|
|
684
|
-
else:
|
|
685
|
-
return self.forwardValue(amount, year)
|
|
686
|
-
|
|
687
|
-
def forwardValue(self, amount, year):
|
|
688
|
-
"""
|
|
689
|
-
Return the value of amount inflated from beginning of the plan
|
|
690
|
-
to the beginning of the year provided.
|
|
691
|
-
"""
|
|
692
|
-
if self.rateMethod is None:
|
|
693
|
-
raise RuntimeError("A rate method needs to be first selected using setRates(...).")
|
|
694
|
-
|
|
695
|
-
thisyear = date.today().year
|
|
696
|
-
if year <= thisyear:
|
|
697
|
-
raise RuntimeError("Internal error in forwardValue().")
|
|
698
|
-
span = year - thisyear
|
|
699
|
-
|
|
700
|
-
return amount * self.gamma_n[span]
|
|
701
|
-
|
|
702
664
|
def setAccountBalances(self, *, taxable, taxDeferred, taxFree, startDate=None, units="k"):
|
|
703
665
|
"""
|
|
704
666
|
Three lists containing the balance of all assets in each category for
|
|
@@ -988,7 +950,6 @@ class Plan(object):
|
|
|
988
950
|
for i, iname in enumerate(self.inames):
|
|
989
951
|
h = self.horizons[i]
|
|
990
952
|
df = pd.DataFrame(0, index=np.arange(0, h+5), columns=cols)
|
|
991
|
-
# df["year"] = self.year_n[:h]
|
|
992
953
|
df["year"] = np.arange(self.year_n[0] - 5, self.year_n[h-1]+1)
|
|
993
954
|
self.timeLists[iname] = df
|
|
994
955
|
|
|
@@ -1026,62 +987,70 @@ class Plan(object):
|
|
|
1026
987
|
|
|
1027
988
|
return dat
|
|
1028
989
|
|
|
1029
|
-
def _adjustParameters(self):
|
|
990
|
+
def _adjustParameters(self, gamma_n, MAGI_n):
|
|
1030
991
|
"""
|
|
1031
|
-
Adjust parameters that follow inflation.
|
|
992
|
+
Adjust parameters that follow inflation or depend on MAGI.
|
|
993
|
+
Separate variables depending on MAGI (exemptions now depends on MAGI).
|
|
1032
994
|
"""
|
|
1033
995
|
if self.rateMethod is None:
|
|
1034
996
|
raise RuntimeError("A rate method needs to be first selected using setRates(...).")
|
|
1035
997
|
|
|
998
|
+
self.sigmaBar_n, self.theta_tn, self.Delta_tn = tx.taxParams(self.yobs, self.i_d, self.n_d,
|
|
999
|
+
self.N_n, gamma_n,
|
|
1000
|
+
MAGI_n, self.yOBBBA)
|
|
1001
|
+
|
|
1036
1002
|
if not self._adjustedParameters:
|
|
1037
1003
|
self.mylog.vprint("Adjusting parameters for inflation.")
|
|
1038
|
-
self.
|
|
1039
|
-
|
|
1040
|
-
self.
|
|
1041
|
-
self.DeltaBar_tn = self.Delta_tn * self.gamma_n[:-1]
|
|
1042
|
-
self.zetaBar_in = self.zeta_in * self.gamma_n[:-1]
|
|
1043
|
-
self.xiBar_n = self.xi_n * self.gamma_n[:-1]
|
|
1004
|
+
self.DeltaBar_tn = self.Delta_tn * gamma_n[:-1]
|
|
1005
|
+
self.zetaBar_in = self.zeta_in * gamma_n[:-1]
|
|
1006
|
+
self.xiBar_n = self.xi_n * gamma_n[:-1]
|
|
1044
1007
|
self.piBar_in = np.array(self.pi_in)
|
|
1045
1008
|
for i in range(self.N_i):
|
|
1046
1009
|
if self.pensionIsIndexed[i]:
|
|
1047
|
-
self.piBar_in[i] *=
|
|
1010
|
+
self.piBar_in[i] *= gamma_n[:-1]
|
|
1011
|
+
|
|
1012
|
+
self.nm, self.L_nq, self.C_nq = tx.mediVals(self.yobs, self.horizons, gamma_n, self.N_n, self.N_q)
|
|
1048
1013
|
|
|
1049
1014
|
self._adjustedParameters = True
|
|
1050
1015
|
|
|
1051
|
-
return None
|
|
1016
|
+
# return None
|
|
1052
1017
|
|
|
1053
|
-
def _buildOffsetMap(self):
|
|
1018
|
+
def _buildOffsetMap(self, options):
|
|
1054
1019
|
"""
|
|
1055
1020
|
Utility function to map variables to a block vector.
|
|
1056
1021
|
Refer to companion document for explanations.
|
|
1022
|
+
All binary variables must be lumped at the end of the vector.
|
|
1057
1023
|
"""
|
|
1024
|
+
medi = options.get("optimizeMedicare", False)
|
|
1025
|
+
|
|
1058
1026
|
# Stack all variables in a single block vector with all binary variables at the end.
|
|
1059
1027
|
C = {}
|
|
1060
1028
|
C["b"] = 0
|
|
1061
1029
|
C["d"] = _qC(C["b"], self.N_i, self.N_j, self.N_n + 1)
|
|
1062
1030
|
C["e"] = _qC(C["d"], self.N_i, self.N_n)
|
|
1063
|
-
C["
|
|
1064
|
-
C["g"] = _qC(C["
|
|
1065
|
-
C["
|
|
1031
|
+
C["f"] = _qC(C["e"], self.N_n)
|
|
1032
|
+
C["g"] = _qC(C["f"], self.N_t, self.N_n)
|
|
1033
|
+
C["m"] = _qC(C["g"], self.N_n)
|
|
1034
|
+
C["s"] = _qC(C["m"], self.N_n)
|
|
1066
1035
|
C["w"] = _qC(C["s"], self.N_n)
|
|
1067
1036
|
C["x"] = _qC(C["w"], self.N_i, self.N_j, self.N_n)
|
|
1068
|
-
C["
|
|
1069
|
-
|
|
1070
|
-
self.
|
|
1071
|
-
|
|
1072
|
-
# # self.nbins = 0
|
|
1037
|
+
C["zx"] = _qC(C["x"], self.N_i, self.N_n)
|
|
1038
|
+
C["zm"] = _qC(C["zx"], self.N_i, self.N_n, self.N_zx)
|
|
1039
|
+
self.nvars = _qC(C["zm"], self.N_n - self.nm, self.N_q - 1) if medi else C["zm"]
|
|
1040
|
+
self.nbins = self.nvars - C["zx"]
|
|
1073
1041
|
|
|
1074
1042
|
self.C = C
|
|
1075
1043
|
self.mylog.vprint(
|
|
1076
1044
|
f"Problem has {len(C)} distinct series, {self.nvars} decision variables (including {self.nbins} binary).")
|
|
1077
1045
|
|
|
1078
|
-
return None
|
|
1079
|
-
|
|
1080
1046
|
def _buildConstraints(self, objective, options):
|
|
1081
1047
|
"""
|
|
1082
1048
|
Utility function that builds constraint matrix and vectors.
|
|
1083
1049
|
Refactored for clarity and maintainability.
|
|
1084
1050
|
"""
|
|
1051
|
+
# Ensure parameters are adjusted for inflation and MAGI.
|
|
1052
|
+
self._adjustParameters(self.gamma_n, self.MAGI_n)
|
|
1053
|
+
|
|
1085
1054
|
self.A = abc.ConstraintMatrix(self.nvars)
|
|
1086
1055
|
self.B = abc.Bounds(self.nvars, self.nbins)
|
|
1087
1056
|
|
|
@@ -1100,11 +1069,11 @@ class Plan(object):
|
|
|
1100
1069
|
self._add_net_cash_flow()
|
|
1101
1070
|
self._add_income_profile()
|
|
1102
1071
|
self._add_taxable_income()
|
|
1103
|
-
self.
|
|
1072
|
+
self._configure_Medicare_binary_variables(options)
|
|
1073
|
+
self._add_Medicare_costs(options)
|
|
1074
|
+
self._configure_exclusion_binary_variables(options)
|
|
1104
1075
|
self._build_objective_vector(objective)
|
|
1105
1076
|
|
|
1106
|
-
return None
|
|
1107
|
-
|
|
1108
1077
|
def _add_rmd_inequalities(self):
|
|
1109
1078
|
for i in range(self.N_i):
|
|
1110
1079
|
if self.beta_ij[i, 1] > 0:
|
|
@@ -1118,7 +1087,7 @@ class Plan(object):
|
|
|
1118
1087
|
def _add_tax_bracket_bounds(self):
|
|
1119
1088
|
for t in range(self.N_t):
|
|
1120
1089
|
for n in range(self.N_n):
|
|
1121
|
-
self.B.setRange(_q2(self.C["
|
|
1090
|
+
self.B.setRange(_q2(self.C["f"], t, n, self.N_t, self.N_n), 0, self.DeltaBar_tn[t, n])
|
|
1122
1091
|
|
|
1123
1092
|
def _add_standard_exemption_bounds(self):
|
|
1124
1093
|
for n in range(self.N_n):
|
|
@@ -1154,20 +1123,18 @@ class Plan(object):
|
|
|
1154
1123
|
row.addElem(_q3(self.C["w"], i, 2, n, self.N_i, self.N_j, self.N_n), -1)
|
|
1155
1124
|
for dn in range(1, 6):
|
|
1156
1125
|
nn = n - dn
|
|
1157
|
-
if nn
|
|
1126
|
+
if nn >= 0: # Past of future is now or in the future: use variables and parameters.
|
|
1127
|
+
Tau1 = 1 + np.sum(self.alpha_ijkn[i, 2, :, nn] * self.tau_kn[:, nn], axis=0)
|
|
1128
|
+
cgains *= Tau1
|
|
1129
|
+
row.addElem(_q2(self.C["x"], i, nn, self.N_i, self.N_n), -cgains)
|
|
1130
|
+
# If a contribution - it can be withdrawn but not the gains.
|
|
1131
|
+
rhs += (cgains - 1) * self.kappa_ijn[i, 2, nn]
|
|
1132
|
+
else: # Past of future is in the past:
|
|
1158
1133
|
# Parameters are stored at the end of contributions and conversions arrays.
|
|
1159
1134
|
cgains *= oldTau1
|
|
1160
|
-
# If
|
|
1135
|
+
# If a contribution, it has no penalty, but assume a conversion.
|
|
1161
1136
|
# rhs += (cgains - 1) * self.kappa_ijn[i, 2, nn] + cgains * self.myRothX_in[i, nn]
|
|
1162
1137
|
rhs += cgains * self.kappa_ijn[i, 2, nn] + cgains * self.myRothX_in[i, nn]
|
|
1163
|
-
else: # Past of future is in the future: use variables and parameters.
|
|
1164
|
-
ksum2 = np.sum(self.alpha_ijkn[i, 2, :, nn] * self.tau_kn[:, nn], axis=0)
|
|
1165
|
-
Tau1 = 1 + ksum2
|
|
1166
|
-
cgains *= Tau1
|
|
1167
|
-
row.addElem(_q2(self.C["x"], i, nn, self.N_i, self.N_n), -cgains)
|
|
1168
|
-
# If only a contribution - without conversion.
|
|
1169
|
-
# rhs += (cgains - 1) * self.kappa_ijn[i, 2, nn]
|
|
1170
|
-
rhs += cgains * self.kappa_ijn[i, 2, nn]
|
|
1171
1138
|
|
|
1172
1139
|
self.A.addRow(row, rhs, np.inf)
|
|
1173
1140
|
|
|
@@ -1323,11 +1290,12 @@ class Plan(object):
|
|
|
1323
1290
|
tau_0prev = np.roll(self.tau_kn[0, :], 1)
|
|
1324
1291
|
tau_0prev[tau_0prev < 0] = 0
|
|
1325
1292
|
for n in range(self.N_n):
|
|
1326
|
-
rhs = -self.M_n[n]
|
|
1293
|
+
rhs = -self.M_n[n] - self.J_n[n]
|
|
1327
1294
|
row = self.A.newRow({_q1(self.C["g"], n, self.N_n): 1})
|
|
1328
1295
|
row.addElem(_q1(self.C["s"], n, self.N_n), 1)
|
|
1296
|
+
row.addElem(_q1(self.C["m"], n, self.N_n), 1)
|
|
1329
1297
|
for i in range(self.N_i):
|
|
1330
|
-
fac = self.
|
|
1298
|
+
fac = self.psi_n[n] * self.alpha_ijkn[i, 0, 0, n]
|
|
1331
1299
|
rhs += (
|
|
1332
1300
|
self.omega_in[i, n]
|
|
1333
1301
|
+ self.zetaBar_in[i, n]
|
|
@@ -1343,7 +1311,7 @@ class Plan(object):
|
|
|
1343
1311
|
row.addElem(_q2(self.C["d"], i, n, self.N_i, self.N_n), fac * self.mu)
|
|
1344
1312
|
|
|
1345
1313
|
for t in range(self.N_t):
|
|
1346
|
-
row.addElem(_q2(self.C["
|
|
1314
|
+
row.addElem(_q2(self.C["f"], t, n, self.N_t, self.N_n), self.theta_tn[t, n])
|
|
1347
1315
|
|
|
1348
1316
|
self.A.addRow(row, rhs, rhs)
|
|
1349
1317
|
|
|
@@ -1373,10 +1341,13 @@ class Plan(object):
|
|
|
1373
1341
|
row.addElem(_q3(self.C["w"], i, 0, n, self.N_i, self.N_j, self.N_n), fak)
|
|
1374
1342
|
row.addElem(_q2(self.C["d"], i, n, self.N_i, self.N_n), -fak)
|
|
1375
1343
|
for t in range(self.N_t):
|
|
1376
|
-
row.addElem(_q2(self.C["
|
|
1344
|
+
row.addElem(_q2(self.C["f"], t, n, self.N_t, self.N_n), 1)
|
|
1377
1345
|
self.A.addRow(row, rhs, rhs)
|
|
1378
1346
|
|
|
1379
|
-
def
|
|
1347
|
+
def _configure_exclusion_binary_variables(self, options):
|
|
1348
|
+
if not options.get("xorConstraints", True):
|
|
1349
|
+
return
|
|
1350
|
+
|
|
1380
1351
|
bigM = options.get("bigM", 5e6)
|
|
1381
1352
|
if not isinstance(bigM, (int, float)):
|
|
1382
1353
|
raise ValueError(f"bigM {bigM} is not a number.")
|
|
@@ -1384,14 +1355,14 @@ class Plan(object):
|
|
|
1384
1355
|
for i in range(self.N_i):
|
|
1385
1356
|
for n in range(self.horizons[i]):
|
|
1386
1357
|
self.A.addNewRow(
|
|
1387
|
-
{_q3(self.C["
|
|
1358
|
+
{_q3(self.C["zx"], i, n, 0, self.N_i, self.N_n, self.N_zx): bigM,
|
|
1388
1359
|
_q1(self.C["s"], n, self.N_n): -1},
|
|
1389
1360
|
0,
|
|
1390
1361
|
bigM,
|
|
1391
1362
|
)
|
|
1392
1363
|
self.A.addNewRow(
|
|
1393
1364
|
{
|
|
1394
|
-
_q3(self.C["
|
|
1365
|
+
_q3(self.C["zx"], i, n, 0, self.N_i, self.N_n, self.N_zx): bigM,
|
|
1395
1366
|
_q3(self.C["w"], i, 0, n, self.N_i, self.N_j, self.N_n): 1,
|
|
1396
1367
|
_q3(self.C["w"], i, 2, n, self.N_i, self.N_j, self.N_n): 1,
|
|
1397
1368
|
},
|
|
@@ -1399,20 +1370,94 @@ class Plan(object):
|
|
|
1399
1370
|
bigM,
|
|
1400
1371
|
)
|
|
1401
1372
|
self.A.addNewRow(
|
|
1402
|
-
{_q3(self.C["
|
|
1373
|
+
{_q3(self.C["zx"], i, n, 1, self.N_i, self.N_n, self.N_zx): bigM,
|
|
1403
1374
|
_q2(self.C["x"], i, n, self.N_i, self.N_n): -1},
|
|
1404
1375
|
0,
|
|
1405
1376
|
bigM,
|
|
1406
1377
|
)
|
|
1407
1378
|
self.A.addNewRow(
|
|
1408
|
-
{_q3(self.C["
|
|
1379
|
+
{_q3(self.C["zx"], i, n, 1, self.N_i, self.N_n, self.N_zx): bigM,
|
|
1409
1380
|
_q3(self.C["w"], i, 2, n, self.N_i, self.N_j, self.N_n): 1},
|
|
1410
1381
|
0,
|
|
1411
1382
|
bigM,
|
|
1412
1383
|
)
|
|
1413
1384
|
for n in range(self.horizons[i], self.N_n):
|
|
1414
|
-
self.B.setRange(_q3(self.C["
|
|
1415
|
-
self.B.setRange(_q3(self.C["
|
|
1385
|
+
self.B.setRange(_q3(self.C["zx"], i, n, 0, self.N_i, self.N_n, self.N_zx), 0, 0)
|
|
1386
|
+
self.B.setRange(_q3(self.C["zx"], i, n, 1, self.N_i, self.N_n, self.N_zx), 0, 0)
|
|
1387
|
+
|
|
1388
|
+
def _configure_Medicare_binary_variables(self, options):
|
|
1389
|
+
if not options.get("optimizeMedicare", False):
|
|
1390
|
+
return
|
|
1391
|
+
|
|
1392
|
+
bigM = options.get("bigM", 5e6)
|
|
1393
|
+
if not isinstance(bigM, (int, float)):
|
|
1394
|
+
raise ValueError(f"bigM {bigM} is not a number.")
|
|
1395
|
+
|
|
1396
|
+
Nmed = self.N_n - self.nm
|
|
1397
|
+
offset = 0
|
|
1398
|
+
if self.nm < 2:
|
|
1399
|
+
offset = 2 - self.nm
|
|
1400
|
+
for nn in range(offset):
|
|
1401
|
+
n = self.nm + nn
|
|
1402
|
+
for q in range(self.N_q - 1):
|
|
1403
|
+
self.A.addNewRow({_q2(self.C["zm"], nn, q, Nmed, self.N_q - 1): bigM},
|
|
1404
|
+
-np.inf, bigM - self.L_nq[nn, q] + self.prevMAGI[n])
|
|
1405
|
+
self.A.addNewRow({_q2(self.C["zm"], nn, q, Nmed, self.N_q - 1): -bigM},
|
|
1406
|
+
-np.inf, self.L_nq[nn, q] - self.prevMAGI[n])
|
|
1407
|
+
|
|
1408
|
+
for nn in range(offset, Nmed):
|
|
1409
|
+
n2 = self.nm + nn - 2 # n - 2
|
|
1410
|
+
for q in range(self.N_q - 1):
|
|
1411
|
+
rhs1 = bigM - self.L_nq[nn, q]
|
|
1412
|
+
rhs2 = self.L_nq[nn, q]
|
|
1413
|
+
row1 = self.A.newRow()
|
|
1414
|
+
row2 = self.A.newRow()
|
|
1415
|
+
|
|
1416
|
+
row1.addElem(_q2(self.C["zm"], nn, q, Nmed, self.N_q - 1), +bigM)
|
|
1417
|
+
row2.addElem(_q2(self.C["zm"], nn, q, Nmed, self.N_q - 1), -bigM)
|
|
1418
|
+
for i in range(self.N_i):
|
|
1419
|
+
row1.addElem(_q3(self.C["w"], i, 1, n2, self.N_i, self.N_j, self.N_n), -1)
|
|
1420
|
+
row2.addElem(_q3(self.C["w"], i, 1, n2, self.N_i, self.N_j, self.N_n), +1)
|
|
1421
|
+
|
|
1422
|
+
row1.addElem(_q2(self.C["x"], i, n2, self.N_i, self.N_n), -1)
|
|
1423
|
+
row2.addElem(_q2(self.C["x"], i, n2, self.N_i, self.N_n), +1)
|
|
1424
|
+
|
|
1425
|
+
afac = (self.mu*self.alpha_ijkn[i, 0, 0, n2]
|
|
1426
|
+
+ np.sum(self.alpha_ijkn[i, 0, 1:, n2]*self.tau_kn[1:, n2]))
|
|
1427
|
+
afac = 0
|
|
1428
|
+
row1.addElem(_q3(self.C["b"], i, 0, n2, self.N_i, self.N_j, self.N_n + 1), -afac)
|
|
1429
|
+
row2.addElem(_q3(self.C["b"], i, 0, n2, self.N_i, self.N_j, self.N_n + 1), +afac)
|
|
1430
|
+
|
|
1431
|
+
row1.addElem(_q2(self.C["d"], i, n2, self.N_i, self.N_n), -afac)
|
|
1432
|
+
row2.addElem(_q2(self.C["d"], i, n2, self.N_i, self.N_n), +afac)
|
|
1433
|
+
|
|
1434
|
+
bfac = self.alpha_ijkn[i, 0, 0, n2] * max(0, self.tau_kn[0, max(0, n2-1)])
|
|
1435
|
+
row1.addElem(_q3(self.C["w"], i, 0, n2, self.N_i, self.N_j, self.N_n), +afac - bfac)
|
|
1436
|
+
row2.addElem(_q3(self.C["w"], i, 0, n2, self.N_i, self.N_j, self.N_n), -afac + bfac)
|
|
1437
|
+
|
|
1438
|
+
sumoni = (self.omega_in[i, n2] + self.psi_n[n2] * self.zetaBar_in[i, n2] + self.piBar_in[i, n2]
|
|
1439
|
+
+ 0.5 * self.kappa_ijn[i, 0, n2] * afac)
|
|
1440
|
+
rhs1 += sumoni
|
|
1441
|
+
rhs2 -= sumoni
|
|
1442
|
+
|
|
1443
|
+
self.A.addRow(row1, -np.inf, rhs1)
|
|
1444
|
+
self.A.addRow(row2, -np.inf, rhs2)
|
|
1445
|
+
|
|
1446
|
+
def _add_Medicare_costs(self, options):
|
|
1447
|
+
if not options.get("optimizeMedicare", False):
|
|
1448
|
+
return
|
|
1449
|
+
|
|
1450
|
+
for n in range(self.nm):
|
|
1451
|
+
self.B.setRange(_q1(self.C["m"], n, self.N_n), 0, 0)
|
|
1452
|
+
|
|
1453
|
+
Nmed = self.N_n - self.nm
|
|
1454
|
+
for nn in range(Nmed):
|
|
1455
|
+
n = self.nm + nn
|
|
1456
|
+
row = self.A.newRow()
|
|
1457
|
+
row.addElem(_q1(self.C["m"], n, self.N_n), 1)
|
|
1458
|
+
for q in range(self.N_q - 1):
|
|
1459
|
+
row.addElem(_q2(self.C["zm"], nn, q, Nmed, self.N_q - 1), -self.C_nq[nn, q+1])
|
|
1460
|
+
self.A.addRow(row, self.C_nq[nn, 0], self.C_nq[nn, 0])
|
|
1416
1461
|
|
|
1417
1462
|
def _build_objective_vector(self, objective):
|
|
1418
1463
|
c = abc.Objective(self.nvars)
|
|
@@ -1497,12 +1542,7 @@ class Plan(object):
|
|
|
1497
1542
|
self.mylog.vprint(f"Running {N} Monte Carlo simulations.")
|
|
1498
1543
|
self.mylog.setVerbose(verbose)
|
|
1499
1544
|
|
|
1500
|
-
|
|
1501
|
-
if "withMedicare" not in options:
|
|
1502
|
-
myoptions = dict(options)
|
|
1503
|
-
myoptions["withMedicare"] = False
|
|
1504
|
-
else:
|
|
1505
|
-
myoptions = options
|
|
1545
|
+
myoptions = options
|
|
1506
1546
|
|
|
1507
1547
|
if objective == "maxSpending":
|
|
1508
1548
|
columns = ["partial", objective]
|
|
@@ -1578,7 +1618,7 @@ class Plan(object):
|
|
|
1578
1618
|
|
|
1579
1619
|
# Check objective and required options.
|
|
1580
1620
|
knownObjectives = ["maxBequest", "maxSpending"]
|
|
1581
|
-
knownSolvers = ["HiGHS", "PuLP/CBC", "MOSEK"]
|
|
1621
|
+
knownSolvers = ["HiGHS", "PuLP/CBC", "PuLP/HiGHS", "MOSEK"]
|
|
1582
1622
|
|
|
1583
1623
|
knownOptions = [
|
|
1584
1624
|
"bequest",
|
|
@@ -1586,13 +1626,16 @@ class Plan(object):
|
|
|
1586
1626
|
"maxRothConversion",
|
|
1587
1627
|
"netSpending",
|
|
1588
1628
|
"noRothConversions",
|
|
1629
|
+
"oppCostX",
|
|
1630
|
+
"optimizeMedicare",
|
|
1589
1631
|
"previousMAGIs",
|
|
1590
1632
|
"solver",
|
|
1591
1633
|
"spendingSlack",
|
|
1592
1634
|
"startRothConversions",
|
|
1593
1635
|
"units",
|
|
1594
|
-
"
|
|
1595
|
-
"
|
|
1636
|
+
"xorConstraints",
|
|
1637
|
+
"withSCLoop",
|
|
1638
|
+
"withMedicare", # Ignore keyword.
|
|
1596
1639
|
]
|
|
1597
1640
|
# We might modify options if required.
|
|
1598
1641
|
options = {} if options is None else options
|
|
@@ -1637,7 +1680,14 @@ class Plan(object):
|
|
|
1637
1680
|
raise ValueError(f"Slack value out of range {lambdha}.")
|
|
1638
1681
|
self.lambdha = lambdha / 100
|
|
1639
1682
|
|
|
1640
|
-
|
|
1683
|
+
# Reset long-term capital gain tax rate and MAGI to zero.
|
|
1684
|
+
self.psi_n = np.zeros(self.N_n)
|
|
1685
|
+
self.MAGI_n = np.zeros(self.N_n)
|
|
1686
|
+
self.J_n = np.zeros(self.N_n)
|
|
1687
|
+
self.M_n = np.zeros(self.N_n)
|
|
1688
|
+
|
|
1689
|
+
self._adjustParameters(self.gamma_n, self.MAGI_n)
|
|
1690
|
+
self._buildOffsetMap(options)
|
|
1641
1691
|
|
|
1642
1692
|
solver = myoptions.get("solver", self.defaultSolver)
|
|
1643
1693
|
if solver not in knownSolvers:
|
|
@@ -1645,10 +1695,10 @@ class Plan(object):
|
|
|
1645
1695
|
|
|
1646
1696
|
if solver == "HiGHS":
|
|
1647
1697
|
solverMethod = self._milpSolve
|
|
1648
|
-
elif solver == "PuLP/CBC":
|
|
1649
|
-
solverMethod = self._pulpSolve
|
|
1650
1698
|
elif solver == "MOSEK":
|
|
1651
1699
|
solverMethod = self._mosekSolve
|
|
1700
|
+
elif "PuLP" in solver:
|
|
1701
|
+
solverMethod = self._pulpSolve
|
|
1652
1702
|
else:
|
|
1653
1703
|
raise RuntimeError("Internal error in defining solverMethod.")
|
|
1654
1704
|
|
|
@@ -1663,7 +1713,8 @@ class Plan(object):
|
|
|
1663
1713
|
"""
|
|
1664
1714
|
Self-consistent loop, regardless of solver.
|
|
1665
1715
|
"""
|
|
1666
|
-
|
|
1716
|
+
optimizeMedicare = options.get("optimizeMedicare", False)
|
|
1717
|
+
withSCLoop = options.get("withSCLoop", True)
|
|
1667
1718
|
|
|
1668
1719
|
if objective == "maxSpending":
|
|
1669
1720
|
objFac = -1 / self.xi_n[0]
|
|
@@ -1671,32 +1722,34 @@ class Plan(object):
|
|
|
1671
1722
|
objFac = -1 / self.gamma_n[-1]
|
|
1672
1723
|
|
|
1673
1724
|
it = 0
|
|
1674
|
-
absdiff = np.inf
|
|
1675
1725
|
old_x = np.zeros(self.nvars)
|
|
1676
|
-
|
|
1677
|
-
self.
|
|
1726
|
+
old_objfns = [np.inf]
|
|
1727
|
+
self._computeNLstuff(None, optimizeMedicare)
|
|
1678
1728
|
while True:
|
|
1679
|
-
|
|
1729
|
+
objfn, xx, solverSuccess, solverMsg = solverMethod(objective, options)
|
|
1680
1730
|
|
|
1681
|
-
if not solverSuccess or
|
|
1731
|
+
if not solverSuccess or objfn is None:
|
|
1682
1732
|
self.mylog.vprint("Solver failed:", solverMsg, solverSuccess)
|
|
1683
1733
|
break
|
|
1684
1734
|
|
|
1685
|
-
if not
|
|
1735
|
+
if not withSCLoop:
|
|
1686
1736
|
break
|
|
1687
1737
|
|
|
1688
|
-
self.
|
|
1689
|
-
|
|
1690
|
-
self.mylog.vprint(f"Iteration: {it} objective: {u.d(solution * objFac, f=2)}")
|
|
1738
|
+
self._computeNLstuff(xx, optimizeMedicare)
|
|
1691
1739
|
|
|
1692
1740
|
delta = xx - old_x
|
|
1693
|
-
|
|
1694
|
-
|
|
1741
|
+
absSolDiff = np.sum(np.abs(delta), axis=0)/100
|
|
1742
|
+
absObjDiff = abs(objFac*(objfn + old_objfns[-1]))/100
|
|
1743
|
+
self.mylog.vprint(f"Iteration: {it} objective: {u.d(objfn * objFac, f=2)},"
|
|
1744
|
+
f" |dX|: {absSolDiff:.2f}, |df|: {u.d(absObjDiff, f=2)}")
|
|
1745
|
+
|
|
1746
|
+
# 50 cents accuracy.
|
|
1747
|
+
if absSolDiff < .5 and absObjDiff < .5:
|
|
1695
1748
|
self.mylog.vprint("Converged on full solution.")
|
|
1696
1749
|
break
|
|
1697
1750
|
|
|
1698
1751
|
# Avoid oscillatory solutions. Look only at most recent solutions. Within $10.
|
|
1699
|
-
isclosenough = abs(-
|
|
1752
|
+
isclosenough = abs(-objfn - min(old_objfns[int(it / 2) :])) < 10 * self.xi_n[0]
|
|
1700
1753
|
if isclosenough:
|
|
1701
1754
|
self.mylog.vprint("Converged through selecting minimum oscillating objective.")
|
|
1702
1755
|
break
|
|
@@ -1706,13 +1759,13 @@ class Plan(object):
|
|
|
1706
1759
|
break
|
|
1707
1760
|
|
|
1708
1761
|
it += 1
|
|
1709
|
-
|
|
1762
|
+
old_objfns.append(-objfn)
|
|
1710
1763
|
old_x = xx
|
|
1711
1764
|
|
|
1712
1765
|
if solverSuccess:
|
|
1713
|
-
self.mylog.vprint(f"Self-consistent
|
|
1766
|
+
self.mylog.vprint(f"Self-consistent loop returned after {it+1} iterations.")
|
|
1714
1767
|
self.mylog.vprint(solverMsg)
|
|
1715
|
-
self.mylog.vprint(f"Objective: {u.d(
|
|
1768
|
+
self.mylog.vprint(f"Objective: {u.d(objfn * objFac)}")
|
|
1716
1769
|
# self.mylog.vprint('Upper bound:', u.d(-solution.mip_dual_bound))
|
|
1717
1770
|
self._aggregateResults(xx)
|
|
1718
1771
|
self._timestamp = datetime.now().strftime("%Y-%m-%d at %H:%M:%S")
|
|
@@ -1734,7 +1787,7 @@ class Plan(object):
|
|
|
1734
1787
|
"disp": False,
|
|
1735
1788
|
"mip_rel_gap": 1e-7,
|
|
1736
1789
|
"presolve": True,
|
|
1737
|
-
"node_limit": 10000 # Limit search nodes for faster solutions
|
|
1790
|
+
# "node_limit": 10000 # Limit search nodes for faster solutions
|
|
1738
1791
|
}
|
|
1739
1792
|
|
|
1740
1793
|
self._buildConstraints(objective, options)
|
|
@@ -1806,7 +1859,13 @@ class Plan(object):
|
|
|
1806
1859
|
# solver = pulp.getSolver("MOSEK")
|
|
1807
1860
|
# prob.solve(solver)
|
|
1808
1861
|
|
|
1809
|
-
|
|
1862
|
+
if "HiGHS" in options["solver"]:
|
|
1863
|
+
solver = pulp.getSolver("HiGHS", msg=False)
|
|
1864
|
+
else:
|
|
1865
|
+
solver = pulp.getSolver("PULP_CBC_CMD", msg=False)
|
|
1866
|
+
|
|
1867
|
+
prob.solve(solver)
|
|
1868
|
+
|
|
1810
1869
|
# Filter out None values and convert to array.
|
|
1811
1870
|
xx = np.array([0 if x[i].varValue is None else x[i].varValue for i in range(self.nvars)])
|
|
1812
1871
|
solution = np.dot(c, xx)
|
|
@@ -1876,26 +1935,49 @@ class Plan(object):
|
|
|
1876
1935
|
|
|
1877
1936
|
return solution, xx, solverSuccess, solverMsg
|
|
1878
1937
|
|
|
1879
|
-
def
|
|
1938
|
+
def _computeNIIT(self, MAGI_n, I_n, Q_n):
|
|
1880
1939
|
"""
|
|
1881
|
-
Compute
|
|
1940
|
+
Compute ACA tax on Dividends (Q) and Interests (I).
|
|
1941
|
+
Pass arguments to better understand dependencies.
|
|
1942
|
+
For accounting for rent and/or trust income, one can easily add a column
|
|
1943
|
+
to the Wages and Contributions file and add yearly amount to Q_n + I_n below.
|
|
1882
1944
|
"""
|
|
1883
|
-
|
|
1945
|
+
J_n = np.zeros(self.N_n)
|
|
1946
|
+
status = len(self.yobs) - 1
|
|
1947
|
+
|
|
1948
|
+
for n in range(self.N_n):
|
|
1949
|
+
if status and n == self.n_d:
|
|
1950
|
+
status -= 1
|
|
1951
|
+
|
|
1952
|
+
Gmax = tx.niitThreshold[status]
|
|
1953
|
+
if MAGI_n[n] > Gmax:
|
|
1954
|
+
J_n[n] = tx.niitRate * min(MAGI_n[n] - Gmax, I_n[n] + Q_n[n])
|
|
1955
|
+
|
|
1956
|
+
return J_n
|
|
1957
|
+
|
|
1958
|
+
def _computeNLstuff(self, x, optimizeMedicare):
|
|
1959
|
+
"""
|
|
1960
|
+
Compute MAGI, Medicare costs, long-term capital gain tax rate, and
|
|
1961
|
+
net investment income tax (NIIT).
|
|
1962
|
+
"""
|
|
1963
|
+
if x is None:
|
|
1964
|
+
self.MAGI_n = np.zeros(self.N_n)
|
|
1965
|
+
self.J_n = np.zeros(self.N_n)
|
|
1884
1966
|
self.M_n = np.zeros(self.N_n)
|
|
1967
|
+
self.psi_n = np.zeros(self.N_n)
|
|
1885
1968
|
return
|
|
1886
1969
|
|
|
1887
|
-
|
|
1888
|
-
MAGI_n = np.zeros(self.N_n)
|
|
1889
|
-
else:
|
|
1890
|
-
self.F_tn = np.array(x[self.C["F"] : self.C["g"]])
|
|
1891
|
-
self.F_tn = self.F_tn.reshape((self.N_t, self.N_n))
|
|
1892
|
-
MAGI_n = np.sum(self.F_tn, axis=0) + np.array(x[self.C["e"] : self.C["F"]])
|
|
1970
|
+
self._aggregateResults(x, short=True)
|
|
1893
1971
|
|
|
1894
|
-
self.
|
|
1972
|
+
self.J_n = self._computeNIIT(self.MAGI_n, self.I_n, self.Q_n)
|
|
1973
|
+
self.psi_n = tx.capitalGainTaxRate(self.N_i, self.MAGI_n, self.gamma_n[:-1], self.n_d, self.N_n)
|
|
1974
|
+
# Compute Medicare through self-consistent loop.
|
|
1975
|
+
if not optimizeMedicare:
|
|
1976
|
+
self.M_n = tx.mediCosts(self.yobs, self.horizons, self.MAGI_n, self.prevMAGI, self.gamma_n[:-1], self.N_n)
|
|
1895
1977
|
|
|
1896
1978
|
return None
|
|
1897
1979
|
|
|
1898
|
-
def _aggregateResults(self, x):
|
|
1980
|
+
def _aggregateResults(self, x, short=False):
|
|
1899
1981
|
"""
|
|
1900
1982
|
Utility function to aggregate results from solver.
|
|
1901
1983
|
Process all results from solution vector.
|
|
@@ -1906,18 +1988,19 @@ class Plan(object):
|
|
|
1906
1988
|
Nk = self.N_k
|
|
1907
1989
|
Nn = self.N_n
|
|
1908
1990
|
Nt = self.N_t
|
|
1909
|
-
#
|
|
1991
|
+
# Nzx = self.N_zx
|
|
1910
1992
|
n_d = self.n_d
|
|
1911
1993
|
|
|
1912
1994
|
Cb = self.C["b"]
|
|
1913
1995
|
Cd = self.C["d"]
|
|
1914
1996
|
Ce = self.C["e"]
|
|
1915
|
-
|
|
1997
|
+
Cf = self.C["f"]
|
|
1916
1998
|
Cg = self.C["g"]
|
|
1999
|
+
Cm = self.C["m"]
|
|
1917
2000
|
Cs = self.C["s"]
|
|
1918
2001
|
Cw = self.C["w"]
|
|
1919
2002
|
Cx = self.C["x"]
|
|
1920
|
-
|
|
2003
|
+
Czx = self.C["zx"]
|
|
1921
2004
|
|
|
1922
2005
|
x = u.roundCents(x)
|
|
1923
2006
|
|
|
@@ -1931,26 +2014,63 @@ class Plan(object):
|
|
|
1931
2014
|
self.d_in = np.array(x[Cd:Ce])
|
|
1932
2015
|
self.d_in = self.d_in.reshape((Ni, Nn))
|
|
1933
2016
|
|
|
1934
|
-
self.e_n = np.array(x[Ce:
|
|
2017
|
+
self.e_n = np.array(x[Ce:Cf])
|
|
1935
2018
|
|
|
1936
|
-
self.
|
|
1937
|
-
self.
|
|
2019
|
+
self.f_tn = np.array(x[Cf:Cg])
|
|
2020
|
+
self.f_tn = self.f_tn.reshape((Nt, Nn))
|
|
1938
2021
|
|
|
1939
|
-
self.g_n = np.array(x[Cg:
|
|
2022
|
+
self.g_n = np.array(x[Cg:Cm])
|
|
2023
|
+
|
|
2024
|
+
self.m_n = np.array(x[Cm:Cs])
|
|
1940
2025
|
|
|
1941
2026
|
self.s_n = np.array(x[Cs:Cw])
|
|
1942
2027
|
|
|
1943
2028
|
self.w_ijn = np.array(x[Cw:Cx])
|
|
1944
2029
|
self.w_ijn = self.w_ijn.reshape((Ni, Nj, Nn))
|
|
1945
2030
|
|
|
1946
|
-
self.x_in = np.array(x[Cx:
|
|
2031
|
+
self.x_in = np.array(x[Cx:Czx])
|
|
1947
2032
|
self.x_in = self.x_in.reshape((Ni, Nn))
|
|
1948
2033
|
|
|
1949
|
-
# self.z_inz = np.array(x[
|
|
1950
|
-
# self.z_inz = self.z_inz.reshape((Ni, Nn,
|
|
2034
|
+
# self.z_inz = np.array(x[Czx:])
|
|
2035
|
+
# self.z_inz = self.z_inz.reshape((Ni, Nn, Nzx))
|
|
1951
2036
|
# print(self.z_inz)
|
|
1952
2037
|
|
|
1953
|
-
|
|
2038
|
+
self.G_n = np.sum(self.f_tn, axis=0)
|
|
2039
|
+
|
|
2040
|
+
tau_0 = np.array(self.tau_kn[0, :])
|
|
2041
|
+
tau_0[tau_0 < 0] = 0
|
|
2042
|
+
# Last year's rates.
|
|
2043
|
+
tau_0prev = np.roll(tau_0, 1)
|
|
2044
|
+
self.Q_n = np.sum(
|
|
2045
|
+
(
|
|
2046
|
+
self.mu
|
|
2047
|
+
* (self.b_ijn[:, 0, :Nn] - self.w_ijn[:, 0, :] + self.d_in[:, :] + 0.5 * self.kappa_ijn[:, 0, :Nn])
|
|
2048
|
+
+ tau_0prev * self.w_ijn[:, 0, :]
|
|
2049
|
+
)
|
|
2050
|
+
* self.alpha_ijkn[:, 0, 0, :Nn],
|
|
2051
|
+
axis=0,
|
|
2052
|
+
)
|
|
2053
|
+
self.U_n = self.psi_n * self.Q_n
|
|
2054
|
+
|
|
2055
|
+
self.MAGI_n = self.G_n + self.e_n + self.Q_n
|
|
2056
|
+
|
|
2057
|
+
I_in = ((self.b_ijn[:, 0, :-1] + self.d_in - self.w_ijn[:, 0, :])
|
|
2058
|
+
* np.sum(self.alpha_ijkn[:, 0, 1:, :Nn] * self.tau_kn[1:, :], axis=1))
|
|
2059
|
+
self.I_n = np.sum(I_in, axis=0)
|
|
2060
|
+
|
|
2061
|
+
# Stop after building minimu required for self-consistent loop.
|
|
2062
|
+
if short:
|
|
2063
|
+
return
|
|
2064
|
+
|
|
2065
|
+
self.T_tn = self.f_tn * self.theta_tn
|
|
2066
|
+
self.T_n = np.sum(self.T_tn, axis=0)
|
|
2067
|
+
self.P_n = np.zeros(Nn)
|
|
2068
|
+
# Add early withdrawal penalty if any.
|
|
2069
|
+
for i in range(Ni):
|
|
2070
|
+
self.P_n[0:self.n59[i]] += 0.1*(self.w_ijn[i, 1, 0:self.n59[i]] + self.w_ijn[i, 2, 0:self.n59[i]])
|
|
2071
|
+
|
|
2072
|
+
self.T_n += self.P_n
|
|
2073
|
+
# Compute partial distribution at the passing of first spouse.
|
|
1954
2074
|
if Ni == 2 and n_d < Nn:
|
|
1955
2075
|
nx = n_d - 1
|
|
1956
2076
|
i_d = self.i_d
|
|
@@ -1976,30 +2096,6 @@ class Plan(object):
|
|
|
1976
2096
|
self.rmd_in = self.rho_in * self.b_ijn[:, 1, :-1]
|
|
1977
2097
|
self.dist_in = self.w_ijn[:, 1, :] - self.rmd_in
|
|
1978
2098
|
self.dist_in[self.dist_in < 0] = 0
|
|
1979
|
-
self.G_n = np.sum(self.F_tn, axis=0)
|
|
1980
|
-
self.T_tn = self.F_tn * self.theta_tn
|
|
1981
|
-
self.T_n = np.sum(self.T_tn, axis=0)
|
|
1982
|
-
self.P_n = np.zeros(Nn)
|
|
1983
|
-
# Add early withdrawal penalty if any.
|
|
1984
|
-
for i in range(Ni):
|
|
1985
|
-
self.P_n[0:self.n59[i]] += 0.1*(self.w_ijn[i, 1, 0:self.n59[i]] + self.w_ijn[i, 2, 0:self.n59[i]])
|
|
1986
|
-
|
|
1987
|
-
self.T_n += self.P_n
|
|
1988
|
-
|
|
1989
|
-
tau_0 = np.array(self.tau_kn[0, :])
|
|
1990
|
-
tau_0[tau_0 < 0] = 0
|
|
1991
|
-
# Last year's rates.
|
|
1992
|
-
tau_0prev = np.roll(tau_0, 1)
|
|
1993
|
-
self.Q_n = np.sum(
|
|
1994
|
-
(
|
|
1995
|
-
self.mu
|
|
1996
|
-
* (self.b_ijn[:, 0, :-1] - self.w_ijn[:, 0, :] + self.d_in[:, :] + 0.5 * self.kappa_ijn[:, 0, :Nn])
|
|
1997
|
-
+ tau_0prev * self.w_ijn[:, 0, :]
|
|
1998
|
-
)
|
|
1999
|
-
* self.alpha_ijkn[:, 0, 0, :-1],
|
|
2000
|
-
axis=0,
|
|
2001
|
-
)
|
|
2002
|
-
self.U_n = self.psi * self.Q_n
|
|
2003
2099
|
|
|
2004
2100
|
# Make derivative variables.
|
|
2005
2101
|
# Putting it all together in a dictionary.
|
|
@@ -2137,8 +2233,13 @@ class Plan(object):
|
|
|
2137
2233
|
dic[" Total tax paid on gains and dividends"] = f"{u.d(taxPaidNow)}"
|
|
2138
2234
|
dic["[Total tax paid on gains and dividends]"] = f"{u.d(taxPaid)}"
|
|
2139
2235
|
|
|
2140
|
-
taxPaid = np.sum(self.
|
|
2141
|
-
taxPaidNow = np.sum(self.
|
|
2236
|
+
taxPaid = np.sum(self.J_n, axis=0)
|
|
2237
|
+
taxPaidNow = np.sum(self.J_n / self.gamma_n[:-1], axis=0)
|
|
2238
|
+
dic[" Total net investment income tax paid"] = f"{u.d(taxPaidNow)}"
|
|
2239
|
+
dic["[Total net investment income tax paid]"] = f"{u.d(taxPaid)}"
|
|
2240
|
+
|
|
2241
|
+
taxPaid = np.sum(self.m_n + self.M_n, axis=0)
|
|
2242
|
+
taxPaidNow = np.sum((self.m_n + self.M_n) / self.gamma_n[:-1], axis=0)
|
|
2142
2243
|
dic[" Total Medicare premiums paid"] = f"{u.d(taxPaidNow)}"
|
|
2143
2244
|
dic["[Total Medicare premiums paid]"] = f"{u.d(taxPaid)}"
|
|
2144
2245
|
|
|
@@ -2280,7 +2381,7 @@ class Plan(object):
|
|
|
2280
2381
|
The value parameter can be set to *nominal* or *today*, overriding
|
|
2281
2382
|
the default behavior of setDefaultPlots().
|
|
2282
2383
|
"""
|
|
2283
|
-
value = self.
|
|
2384
|
+
value = self._checkValueType(value)
|
|
2284
2385
|
title = self._name + "\nNet Available Spending"
|
|
2285
2386
|
if tag:
|
|
2286
2387
|
title += " - " + tag
|
|
@@ -2305,7 +2406,7 @@ class Plan(object):
|
|
|
2305
2406
|
The value parameter can be set to *nominal* or *today*, overriding
|
|
2306
2407
|
the default behavior of setDefaultPlots().
|
|
2307
2408
|
"""
|
|
2308
|
-
value = self.
|
|
2409
|
+
value = self._checkValueType(value)
|
|
2309
2410
|
figures = self._plotter.plot_asset_composition(self.year_n, self.inames, self.b_ijkn,
|
|
2310
2411
|
self.gamma_n, value, self._name, tag)
|
|
2311
2412
|
if figure:
|
|
@@ -2325,8 +2426,8 @@ class Plan(object):
|
|
|
2325
2426
|
The value parameter can be set to *nominal* or *today*, overriding
|
|
2326
2427
|
the default behavior of setDefaultPlots().
|
|
2327
2428
|
"""
|
|
2328
|
-
value = self.
|
|
2329
|
-
tax_brackets = tx.taxBrackets(self.N_i, self.n_d, self.N_n, self.
|
|
2429
|
+
value = self._checkValueType(value)
|
|
2430
|
+
tax_brackets = tx.taxBrackets(self.N_i, self.n_d, self.N_n, self.yOBBBA)
|
|
2330
2431
|
title = self._name + "\nTaxable Ordinary Income vs. Tax Brackets"
|
|
2331
2432
|
if tag:
|
|
2332
2433
|
title += " - " + tag
|
|
@@ -2369,7 +2470,7 @@ class Plan(object):
|
|
|
2369
2470
|
The value parameter can be set to *nominal* or *today*, overriding
|
|
2370
2471
|
the default behavior of setDefaultPlots().
|
|
2371
2472
|
"""
|
|
2372
|
-
value = self.
|
|
2473
|
+
value = self._checkValueType(value)
|
|
2373
2474
|
title = self._name + "\nSavings Balance"
|
|
2374
2475
|
if tag:
|
|
2375
2476
|
title += " - " + tag
|
|
@@ -2391,7 +2492,7 @@ class Plan(object):
|
|
|
2391
2492
|
The value parameter can be set to *nominal* or *today*, overriding
|
|
2392
2493
|
the default behavior of setDefaultPlots().
|
|
2393
2494
|
"""
|
|
2394
|
-
value = self.
|
|
2495
|
+
value = self._checkValueType(value)
|
|
2395
2496
|
title = self._name + "\nRaw Income Sources"
|
|
2396
2497
|
if tag:
|
|
2397
2498
|
title += " - " + tag
|
|
@@ -2413,13 +2514,13 @@ class Plan(object):
|
|
|
2413
2514
|
The value parameter can be set to *nominal* or *today*, overriding
|
|
2414
2515
|
the default behavior of setDefaultPlots().
|
|
2415
2516
|
"""
|
|
2416
|
-
value = self.
|
|
2517
|
+
value = self._checkValueType(value)
|
|
2417
2518
|
title = self._name + "\nFederal Income Tax"
|
|
2418
2519
|
if tag:
|
|
2419
2520
|
title += " - " + tag
|
|
2420
|
-
# All taxes: ordinary income and
|
|
2421
|
-
allTaxes = self.T_n + self.U_n
|
|
2422
|
-
fig = self._plotter.plot_taxes(self.year_n, allTaxes, self.M_n, self.gamma_n,
|
|
2521
|
+
# All taxes: ordinary income, dividends, and NIIT.
|
|
2522
|
+
allTaxes = self.T_n + self.U_n + self.J_n
|
|
2523
|
+
fig = self._plotter.plot_taxes(self.year_n, allTaxes, self.m_n + self.M_n, self.gamma_n,
|
|
2423
2524
|
value, title, self.inames)
|
|
2424
2525
|
if figure:
|
|
2425
2526
|
return fig
|
|
@@ -2490,7 +2591,7 @@ class Plan(object):
|
|
|
2490
2591
|
"net spending": self.g_n,
|
|
2491
2592
|
"taxable ord. income": self.G_n,
|
|
2492
2593
|
"taxable gains/divs": self.Q_n,
|
|
2493
|
-
"Tax bills + Med.": self.T_n + self.U_n + self.M_n,
|
|
2594
|
+
"Tax bills + Med.": self.T_n + self.U_n + self.m_n + self.M_n + self.J_n,
|
|
2494
2595
|
}
|
|
2495
2596
|
|
|
2496
2597
|
fillsheet(ws, incomeDic, "currency")
|
|
@@ -2504,9 +2605,9 @@ class Plan(object):
|
|
|
2504
2605
|
"all BTI's": np.sum(self.Lambda_in, axis=0),
|
|
2505
2606
|
"all wdrwls": np.sum(self.w_ijn, axis=(0, 1)),
|
|
2506
2607
|
"all deposits": -np.sum(self.d_in, axis=0),
|
|
2507
|
-
"ord taxes": -self.T_n,
|
|
2608
|
+
"ord taxes": -self.T_n - self.J_n,
|
|
2508
2609
|
"div taxes": -self.U_n,
|
|
2509
|
-
"Medicare": -self.M_n,
|
|
2610
|
+
"Medicare": -self.m_n - self.M_n,
|
|
2510
2611
|
}
|
|
2511
2612
|
sname = "Cash Flow"
|
|
2512
2613
|
ws = wb.create_sheet(sname)
|
|
@@ -2693,7 +2794,7 @@ def _saveWorkbook(wb, basename, overwrite, mylog):
|
|
|
2693
2794
|
else:
|
|
2694
2795
|
fname = basename
|
|
2695
2796
|
|
|
2696
|
-
if overwrite
|
|
2797
|
+
if not overwrite and isfile(fname):
|
|
2697
2798
|
mylog.print(f'File "{fname}" already exists.')
|
|
2698
2799
|
key = input("Overwrite? [Ny] ")
|
|
2699
2800
|
if key != "y":
|