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/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 educatonal purposes only and does not constitute financial advice.
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.N_z = 2
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
- # Default year TCJA is speculated to expire.
262
- self.yTCJA = 2026
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.psi = 0.15 # Long-term income tax rate on capital gains (decimal)
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, 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 3 years for Medicare.
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 previous balance to none.
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 'month/day'.
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 _checkValue(self, value):
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._checkValue(value)
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 <= 100):
473
- raise ValueError("Rate must be between 0 and 100.")
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 setExpirationYearTCJA(self, yTCJA):
482
+ def setExpirationYearOBBBA(self, yOBBBA):
480
483
  """
481
- Set year at which TCJA is speculated to expire.
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 TCJA expiration year to {yTCJA}.")
484
- self.yTCJA = yTCJA
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=(False, False), units="k"):
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 len(indexed) < self.N_i:
540
- raise ValueError(f"Indexed list must have at least {self.N_i} entries.")
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, self.yobs[i] + ages[i] - thisyear)
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, self.yobs[i] + ages[i] - thisyear)
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.sigma_n, self.theta_tn, self.Delta_tn = tx.taxParams(self.yobs, self.i_d, self.n_d,
1039
- self.N_n, self.yTCJA)
1040
- self.sigmaBar_n = self.sigma_n * self.gamma_n[:-1]
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] *= self.gamma_n[:-1]
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["F"] = _qC(C["e"], self.N_n)
1064
- C["g"] = _qC(C["F"], self.N_t, self.N_n)
1065
- C["s"] = _qC(C["g"], self.N_n)
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["z"] = _qC(C["x"], self.N_i, self.N_n)
1069
- self.nvars = _qC(C["z"], self.N_i, self.N_n, self.N_z)
1070
- self.nbins = self.nvars - C["z"]
1071
- # # self.nvars = _qC(C["x"], self.N_i, self.N_n)
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._configure_binary_variables(options)
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["F"], t, n, self.N_t, self.N_n), 0, self.DeltaBar_tn[t, n])
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 < 0: # Past of future is in the past:
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 only an contribution - without conversion.
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.psi * self.alpha_ijkn[i, 0, 0, n]
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["F"], t, n, self.N_t, self.N_n), self.theta_tn[t, n])
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["F"], t, n, self.N_t, self.N_n), 1)
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 _configure_binary_variables(self, options):
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["z"], i, n, 0, self.N_i, self.N_n, self.N_z): bigM,
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["z"], i, n, 0, self.N_i, self.N_n, self.N_z): bigM,
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["z"], i, n, 1, self.N_i, self.N_n, self.N_z): bigM,
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["z"], i, n, 1, self.N_i, self.N_n, self.N_z): bigM,
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["z"], i, n, 0, self.N_i, self.N_n, self.N_z), 0, 0)
1415
- self.B.setRange(_q3(self.C["z"], i, n, 1, self.N_i, self.N_n, self.N_z), 0, 0)
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
- # Turn off Medicare by default, unless specified in options.
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
- "withMedicare",
1595
- "oppCostX",
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
- self._adjustParameters()
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
- withMedicare = options.get("withMedicare", True)
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
- old_solutions = [np.inf]
1677
- self._estimateMedicare(None, withMedicare)
1726
+ old_objfns = [np.inf]
1727
+ self._computeNLstuff(None, optimizeMedicare)
1678
1728
  while True:
1679
- solution, xx, solverSuccess, solverMsg = solverMethod(objective, options)
1729
+ objfn, xx, solverSuccess, solverMsg = solverMethod(objective, options)
1680
1730
 
1681
- if not solverSuccess or solution is None:
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 withMedicare:
1735
+ if not withSCLoop:
1686
1736
  break
1687
1737
 
1688
- self._estimateMedicare(xx)
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
- absdiff = np.sum(np.abs(delta), axis=0)
1694
- if absdiff < 1:
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(-solution - min(old_solutions[int(it / 2) :])) < 10 * self.xi_n[0]
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
- old_solutions.append(-solution)
1762
+ old_objfns.append(-objfn)
1710
1763
  old_x = xx
1711
1764
 
1712
1765
  if solverSuccess:
1713
- self.mylog.vprint(f"Self-consistent Medicare loop returned after {it} iterations.")
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(solution * objFac)}")
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
- prob.solve(pulp.PULP_CBC_CMD(msg=False))
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 _estimateMedicare(self, x=None, withMedicare=True):
1938
+ def _computeNIIT(self, MAGI_n, I_n, Q_n):
1880
1939
  """
1881
- Compute rough MAGI and Medicare costs.
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
- if withMedicare is False:
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
- if x is None:
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.M_n = tx.mediCosts(self.yobs, self.horizons, MAGI_n, self.prevMAGI, self.gamma_n[:-1], self.N_n)
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
- # Nz = self.N_z
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
- CF = self.C["F"]
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
- Cz = self.C["z"]
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:CF])
2017
+ self.e_n = np.array(x[Ce:Cf])
1935
2018
 
1936
- self.F_tn = np.array(x[CF:Cg])
1937
- self.F_tn = self.F_tn.reshape((Nt, Nn))
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:Cs])
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:Cz])
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[Cz:])
1950
- # self.z_inz = self.z_inz.reshape((Ni, Nn, Nz))
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
- # Partial distribution at the passing of first spouse.
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.M_n, axis=0)
2141
- taxPaidNow = np.sum(self.M_n / self.gamma_n[:-1], axis=0)
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._checkValue(value)
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._checkValue(value)
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._checkValue(value)
2329
- tax_brackets = tx.taxBrackets(self.N_i, self.n_d, self.N_n, self.yTCJA)
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._checkValue(value)
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._checkValue(value)
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._checkValue(value)
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 dividends.
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 is False and isfile(fname):
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":