owlplanner 2025.7.1__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 CHANGED
@@ -18,7 +18,7 @@ A for matrix, B for bounds, C for constraints. Thus the name ABCAPI.
18
18
 
19
19
  Copyright © 2024 - Martin-D. Lacasse
20
20
 
21
- Disclaimers: This code is for educatonal purposes only and does not constitute financial advice.
21
+ Disclaimers: This code is for educational purposes only and does not constitute financial advice.
22
22
 
23
23
  """
24
24
 
owlplanner/config.py CHANGED
@@ -6,7 +6,7 @@ This file contains utility functions to save case parameters.
6
6
 
7
7
  Copyright © 2024 - Martin-D. Lacasse
8
8
 
9
- Disclaimers: This code is for educatonal purposes only and does not constitute financial advice.
9
+ Disclaimers: This code is for educational purposes only and does not constitute financial advice.
10
10
 
11
11
  """
12
12
 
@@ -66,7 +66,7 @@ def saveConfig(myplan, file, mylog):
66
66
  diconf["Rates Selection"] = {
67
67
  "Heirs rate on tax-deferred estate": float(100 * myplan.nu),
68
68
  "Dividend rate": float(100 * myplan.mu),
69
- "TCJA expiration year": myplan.yTCJA,
69
+ "OBBBA expiration year": myplan.yOBBBA,
70
70
  "Method": myplan.rateMethod,
71
71
  }
72
72
  if myplan.rateMethod in ["user", "stochastic"]:
@@ -228,7 +228,7 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
228
228
  # Rates Selection.
229
229
  p.setDividendRate(float(diconf["Rates Selection"].get("Dividend rate", 1.8))) # Fix for mod.
230
230
  p.setHeirsTaxRate(float(diconf["Rates Selection"]["Heirs rate on tax-deferred estate"]))
231
- p.yTCJA = int(diconf["Rates Selection"]["TCJA expiration year"])
231
+ p.yOBBBA = int(diconf["Rates Selection"].get("OBBBA expiration year", 2032))
232
232
 
233
233
  frm = None
234
234
  to = None
owlplanner/mylogging.py CHANGED
@@ -6,7 +6,7 @@ This file contains routines for handling error messages.
6
6
 
7
7
  Copyright © 2024 - Martin-D. Lacasse
8
8
 
9
- Disclaimers: This code is for educatonal purposes only and does not constitute financial advice.
9
+ Disclaimers: This code is for educational purposes only and does not constitute financial advice.
10
10
 
11
11
  """
12
12
 
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)
@@ -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,25 +465,26 @@ 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
 
@@ -516,7 +519,7 @@ class Plan(object):
516
519
  self.nu = nu
517
520
  self.caseStatus = "modified"
518
521
 
519
- def setPension(self, amounts, ages, indexed=(False, False), units="k"):
522
+ def setPension(self, amounts, ages, indexed=None, units="k"):
520
523
  """
521
524
  Set value of pension for each individual and commencement age.
522
525
  Units are in $k, unless specified otherwise: 'k', 'M', or '1'.
@@ -525,8 +528,8 @@ class Plan(object):
525
528
  raise ValueError(f"Amounts must have {self.N_i} entries.")
526
529
  if len(ages) != self.N_i:
527
530
  raise ValueError(f"Ages must have {self.N_i} entries.")
528
- if len(indexed) < self.N_i:
529
- raise ValueError(f"Indexed list must have at least {self.N_i} entries.")
531
+ if indexed is None:
532
+ indexed = [False] * self.N_i
530
533
 
531
534
  fac = u.getUnits(units)
532
535
  amounts = u.rescale(amounts, fac)
@@ -539,7 +542,7 @@ class Plan(object):
539
542
  self.pi_in = np.zeros((self.N_i, self.N_n))
540
543
  for i in range(self.N_i):
541
544
  if amounts[i] != 0:
542
- ns = max(0, self.yobs[i] + ages[i] - thisyear)
545
+ ns = max(0, ages[i] - thisyear + self.yobs[i])
543
546
  nd = self.horizons[i]
544
547
  self.pi_in[i, ns:nd] = amounts[i]
545
548
 
@@ -570,13 +573,13 @@ class Plan(object):
570
573
  thisyear = date.today().year
571
574
  self.zeta_in = np.zeros((self.N_i, self.N_n))
572
575
  for i in range(self.N_i):
573
- ns = max(0, self.yobs[i] + ages[i] - thisyear)
576
+ ns = max(0, ages[i] - thisyear + self.yobs[i])
574
577
  nd = self.horizons[i]
575
578
  self.zeta_in[i, ns:nd] = amounts[i]
576
579
 
577
580
  if self.N_i == 2:
578
- # Approximate calculation for spousal benefit (only valid at FRA).
579
- 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])
580
583
 
581
584
  self.ssecAmounts = np.array(amounts)
582
585
  self.ssecAges = np.array(ages, dtype=np.int32)
@@ -658,36 +661,6 @@ class Plan(object):
658
661
  corr=self.rateCorr,
659
662
  )
660
663
 
661
- def value(self, amount, year):
662
- """
663
- Return value of amount deflated or inflated at the beginning
664
- of the year specified.
665
- If year is in the past, value is made at the beginning of this year.
666
- If year is in the future, amount is adjusted from a reference time
667
- aligned with the beginning of the plan to the beginning of the
668
- year specified.
669
- """
670
- thisyear = date.today().year
671
- if year <= thisyear:
672
- return rates.historicalValue(amount, year)
673
- else:
674
- return self.forwardValue(amount, year)
675
-
676
- def forwardValue(self, amount, year):
677
- """
678
- Return the value of amount inflated from beginning of the plan
679
- to the beginning of the year provided.
680
- """
681
- if self.rateMethod is None:
682
- raise RuntimeError("A rate method needs to be first selected using setRates(...).")
683
-
684
- thisyear = date.today().year
685
- if year <= thisyear:
686
- raise RuntimeError("Internal error in forwardValue().")
687
- span = year - thisyear
688
-
689
- return amount * self.gamma_n[span]
690
-
691
664
  def setAccountBalances(self, *, taxable, taxDeferred, taxFree, startDate=None, units="k"):
692
665
  """
693
666
  Three lists containing the balance of all assets in each category for
@@ -977,7 +950,6 @@ class Plan(object):
977
950
  for i, iname in enumerate(self.inames):
978
951
  h = self.horizons[i]
979
952
  df = pd.DataFrame(0, index=np.arange(0, h+5), columns=cols)
980
- # df["year"] = self.year_n[:h]
981
953
  df["year"] = np.arange(self.year_n[0] - 5, self.year_n[h-1]+1)
982
954
  self.timeLists[iname] = df
983
955
 
@@ -1015,62 +987,70 @@ class Plan(object):
1015
987
 
1016
988
  return dat
1017
989
 
1018
- def _adjustParameters(self):
990
+ def _adjustParameters(self, gamma_n, MAGI_n):
1019
991
  """
1020
- 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).
1021
994
  """
1022
995
  if self.rateMethod is None:
1023
996
  raise RuntimeError("A rate method needs to be first selected using setRates(...).")
1024
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
+
1025
1002
  if not self._adjustedParameters:
1026
1003
  self.mylog.vprint("Adjusting parameters for inflation.")
1027
- self.sigma_n, self.theta_tn, self.Delta_tn = tx.taxParams(self.yobs, self.i_d, self.n_d,
1028
- self.N_n, self.yTCJA)
1029
- self.sigmaBar_n = self.sigma_n * self.gamma_n[:-1]
1030
- self.DeltaBar_tn = self.Delta_tn * self.gamma_n[:-1]
1031
- self.zetaBar_in = self.zeta_in * self.gamma_n[:-1]
1032
- 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]
1033
1007
  self.piBar_in = np.array(self.pi_in)
1034
1008
  for i in range(self.N_i):
1035
1009
  if self.pensionIsIndexed[i]:
1036
- 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)
1037
1013
 
1038
1014
  self._adjustedParameters = True
1039
1015
 
1040
- return None
1016
+ # return None
1041
1017
 
1042
- def _buildOffsetMap(self):
1018
+ def _buildOffsetMap(self, options):
1043
1019
  """
1044
1020
  Utility function to map variables to a block vector.
1045
1021
  Refer to companion document for explanations.
1022
+ All binary variables must be lumped at the end of the vector.
1046
1023
  """
1024
+ medi = options.get("optimizeMedicare", False)
1025
+
1047
1026
  # Stack all variables in a single block vector with all binary variables at the end.
1048
1027
  C = {}
1049
1028
  C["b"] = 0
1050
1029
  C["d"] = _qC(C["b"], self.N_i, self.N_j, self.N_n + 1)
1051
1030
  C["e"] = _qC(C["d"], self.N_i, self.N_n)
1052
- C["F"] = _qC(C["e"], self.N_n)
1053
- C["g"] = _qC(C["F"], self.N_t, self.N_n)
1054
- 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)
1055
1035
  C["w"] = _qC(C["s"], self.N_n)
1056
1036
  C["x"] = _qC(C["w"], self.N_i, self.N_j, self.N_n)
1057
- C["z"] = _qC(C["x"], self.N_i, self.N_n)
1058
- self.nvars = _qC(C["z"], self.N_i, self.N_n, self.N_z)
1059
- self.nbins = self.nvars - C["z"]
1060
- # # self.nvars = _qC(C["x"], self.N_i, self.N_n)
1061
- # # 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"]
1062
1041
 
1063
1042
  self.C = C
1064
1043
  self.mylog.vprint(
1065
1044
  f"Problem has {len(C)} distinct series, {self.nvars} decision variables (including {self.nbins} binary).")
1066
1045
 
1067
- return None
1068
-
1069
1046
  def _buildConstraints(self, objective, options):
1070
1047
  """
1071
1048
  Utility function that builds constraint matrix and vectors.
1072
1049
  Refactored for clarity and maintainability.
1073
1050
  """
1051
+ # Ensure parameters are adjusted for inflation and MAGI.
1052
+ self._adjustParameters(self.gamma_n, self.MAGI_n)
1053
+
1074
1054
  self.A = abc.ConstraintMatrix(self.nvars)
1075
1055
  self.B = abc.Bounds(self.nvars, self.nbins)
1076
1056
 
@@ -1089,11 +1069,11 @@ class Plan(object):
1089
1069
  self._add_net_cash_flow()
1090
1070
  self._add_income_profile()
1091
1071
  self._add_taxable_income()
1092
- 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)
1093
1075
  self._build_objective_vector(objective)
1094
1076
 
1095
- return None
1096
-
1097
1077
  def _add_rmd_inequalities(self):
1098
1078
  for i in range(self.N_i):
1099
1079
  if self.beta_ij[i, 1] > 0:
@@ -1107,7 +1087,7 @@ class Plan(object):
1107
1087
  def _add_tax_bracket_bounds(self):
1108
1088
  for t in range(self.N_t):
1109
1089
  for n in range(self.N_n):
1110
- 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])
1111
1091
 
1112
1092
  def _add_standard_exemption_bounds(self):
1113
1093
  for n in range(self.N_n):
@@ -1313,6 +1293,7 @@ class Plan(object):
1313
1293
  rhs = -self.M_n[n] - self.J_n[n]
1314
1294
  row = self.A.newRow({_q1(self.C["g"], n, self.N_n): 1})
1315
1295
  row.addElem(_q1(self.C["s"], n, self.N_n), 1)
1296
+ row.addElem(_q1(self.C["m"], n, self.N_n), 1)
1316
1297
  for i in range(self.N_i):
1317
1298
  fac = self.psi_n[n] * self.alpha_ijkn[i, 0, 0, n]
1318
1299
  rhs += (
@@ -1330,7 +1311,7 @@ class Plan(object):
1330
1311
  row.addElem(_q2(self.C["d"], i, n, self.N_i, self.N_n), fac * self.mu)
1331
1312
 
1332
1313
  for t in range(self.N_t):
1333
- 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])
1334
1315
 
1335
1316
  self.A.addRow(row, rhs, rhs)
1336
1317
 
@@ -1360,10 +1341,13 @@ class Plan(object):
1360
1341
  row.addElem(_q3(self.C["w"], i, 0, n, self.N_i, self.N_j, self.N_n), fak)
1361
1342
  row.addElem(_q2(self.C["d"], i, n, self.N_i, self.N_n), -fak)
1362
1343
  for t in range(self.N_t):
1363
- 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)
1364
1345
  self.A.addRow(row, rhs, rhs)
1365
1346
 
1366
- def _configure_binary_variables(self, options):
1347
+ def _configure_exclusion_binary_variables(self, options):
1348
+ if not options.get("xorConstraints", True):
1349
+ return
1350
+
1367
1351
  bigM = options.get("bigM", 5e6)
1368
1352
  if not isinstance(bigM, (int, float)):
1369
1353
  raise ValueError(f"bigM {bigM} is not a number.")
@@ -1371,14 +1355,14 @@ class Plan(object):
1371
1355
  for i in range(self.N_i):
1372
1356
  for n in range(self.horizons[i]):
1373
1357
  self.A.addNewRow(
1374
- {_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,
1375
1359
  _q1(self.C["s"], n, self.N_n): -1},
1376
1360
  0,
1377
1361
  bigM,
1378
1362
  )
1379
1363
  self.A.addNewRow(
1380
1364
  {
1381
- _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,
1382
1366
  _q3(self.C["w"], i, 0, n, self.N_i, self.N_j, self.N_n): 1,
1383
1367
  _q3(self.C["w"], i, 2, n, self.N_i, self.N_j, self.N_n): 1,
1384
1368
  },
@@ -1386,20 +1370,94 @@ class Plan(object):
1386
1370
  bigM,
1387
1371
  )
1388
1372
  self.A.addNewRow(
1389
- {_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,
1390
1374
  _q2(self.C["x"], i, n, self.N_i, self.N_n): -1},
1391
1375
  0,
1392
1376
  bigM,
1393
1377
  )
1394
1378
  self.A.addNewRow(
1395
- {_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,
1396
1380
  _q3(self.C["w"], i, 2, n, self.N_i, self.N_j, self.N_n): 1},
1397
1381
  0,
1398
1382
  bigM,
1399
1383
  )
1400
1384
  for n in range(self.horizons[i], self.N_n):
1401
- self.B.setRange(_q3(self.C["z"], i, n, 0, self.N_i, self.N_n, self.N_z), 0, 0)
1402
- 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])
1403
1461
 
1404
1462
  def _build_objective_vector(self, objective):
1405
1463
  c = abc.Objective(self.nvars)
@@ -1484,12 +1542,7 @@ class Plan(object):
1484
1542
  self.mylog.vprint(f"Running {N} Monte Carlo simulations.")
1485
1543
  self.mylog.setVerbose(verbose)
1486
1544
 
1487
- # Turn off Medicare by default, unless specified in options.
1488
- if "withMedicare" not in options:
1489
- myoptions = dict(options)
1490
- myoptions["withMedicare"] = False
1491
- else:
1492
- myoptions = options
1545
+ myoptions = options
1493
1546
 
1494
1547
  if objective == "maxSpending":
1495
1548
  columns = ["partial", objective]
@@ -1573,13 +1626,16 @@ class Plan(object):
1573
1626
  "maxRothConversion",
1574
1627
  "netSpending",
1575
1628
  "noRothConversions",
1629
+ "oppCostX",
1630
+ "optimizeMedicare",
1576
1631
  "previousMAGIs",
1577
1632
  "solver",
1578
1633
  "spendingSlack",
1579
1634
  "startRothConversions",
1580
1635
  "units",
1581
- "withMedicare",
1582
- "oppCostX",
1636
+ "xorConstraints",
1637
+ "withSCLoop",
1638
+ "withMedicare", # Ignore keyword.
1583
1639
  ]
1584
1640
  # We might modify options if required.
1585
1641
  options = {} if options is None else options
@@ -1624,11 +1680,14 @@ class Plan(object):
1624
1680
  raise ValueError(f"Slack value out of range {lambdha}.")
1625
1681
  self.lambdha = lambdha / 100
1626
1682
 
1627
- # Ensure parameters are adjusted for inflation.
1628
- 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)
1629
1688
 
1630
- # Reset long-term capital gain tax rate to zero.
1631
- self.psi_n[:] = 0
1689
+ self._adjustParameters(self.gamma_n, self.MAGI_n)
1690
+ self._buildOffsetMap(options)
1632
1691
 
1633
1692
  solver = myoptions.get("solver", self.defaultSolver)
1634
1693
  if solver not in knownSolvers:
@@ -1654,7 +1713,8 @@ class Plan(object):
1654
1713
  """
1655
1714
  Self-consistent loop, regardless of solver.
1656
1715
  """
1657
- withMedicare = options.get("withMedicare", True)
1716
+ optimizeMedicare = options.get("optimizeMedicare", False)
1717
+ withSCLoop = options.get("withSCLoop", True)
1658
1718
 
1659
1719
  if objective == "maxSpending":
1660
1720
  objFac = -1 / self.xi_n[0]
@@ -1662,32 +1722,34 @@ class Plan(object):
1662
1722
  objFac = -1 / self.gamma_n[-1]
1663
1723
 
1664
1724
  it = 0
1665
- absdiff = np.inf
1666
1725
  old_x = np.zeros(self.nvars)
1667
- old_solutions = [np.inf]
1668
- self._estimateMedicare(None, withMedicare)
1726
+ old_objfns = [np.inf]
1727
+ self._computeNLstuff(None, optimizeMedicare)
1669
1728
  while True:
1670
- solution, xx, solverSuccess, solverMsg = solverMethod(objective, options)
1729
+ objfn, xx, solverSuccess, solverMsg = solverMethod(objective, options)
1671
1730
 
1672
- if not solverSuccess or solution is None:
1731
+ if not solverSuccess or objfn is None:
1673
1732
  self.mylog.vprint("Solver failed:", solverMsg, solverSuccess)
1674
1733
  break
1675
1734
 
1676
- if not withMedicare:
1735
+ if not withSCLoop:
1677
1736
  break
1678
1737
 
1679
- self._estimateMedicare(xx)
1680
-
1681
- self.mylog.vprint(f"Iteration: {it} objective: {u.d(solution * objFac, f=2)}")
1738
+ self._computeNLstuff(xx, optimizeMedicare)
1682
1739
 
1683
1740
  delta = xx - old_x
1684
- absdiff = np.sum(np.abs(delta), axis=0)
1685
- 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:
1686
1748
  self.mylog.vprint("Converged on full solution.")
1687
1749
  break
1688
1750
 
1689
1751
  # Avoid oscillatory solutions. Look only at most recent solutions. Within $10.
1690
- 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]
1691
1753
  if isclosenough:
1692
1754
  self.mylog.vprint("Converged through selecting minimum oscillating objective.")
1693
1755
  break
@@ -1697,13 +1759,13 @@ class Plan(object):
1697
1759
  break
1698
1760
 
1699
1761
  it += 1
1700
- old_solutions.append(-solution)
1762
+ old_objfns.append(-objfn)
1701
1763
  old_x = xx
1702
1764
 
1703
1765
  if solverSuccess:
1704
- self.mylog.vprint(f"Self-consistent Medicare loop returned after {it+1} iterations.")
1766
+ self.mylog.vprint(f"Self-consistent loop returned after {it+1} iterations.")
1705
1767
  self.mylog.vprint(solverMsg)
1706
- self.mylog.vprint(f"Objective: {u.d(solution * objFac)}")
1768
+ self.mylog.vprint(f"Objective: {u.d(objfn * objFac)}")
1707
1769
  # self.mylog.vprint('Upper bound:', u.d(-solution.mip_dual_bound))
1708
1770
  self._aggregateResults(xx)
1709
1771
  self._timestamp = datetime.now().strftime("%Y-%m-%d at %H:%M:%S")
@@ -1725,7 +1787,7 @@ class Plan(object):
1725
1787
  "disp": False,
1726
1788
  "mip_rel_gap": 1e-7,
1727
1789
  "presolve": True,
1728
- "node_limit": 10000 # Limit search nodes for faster solutions
1790
+ # "node_limit": 10000 # Limit search nodes for faster solutions
1729
1791
  }
1730
1792
 
1731
1793
  self._buildConstraints(objective, options)
@@ -1873,10 +1935,11 @@ class Plan(object):
1873
1935
 
1874
1936
  return solution, xx, solverSuccess, solverMsg
1875
1937
 
1876
- def _computeNIIT(self):
1938
+ def _computeNIIT(self, MAGI_n, I_n, Q_n):
1877
1939
  """
1878
- Compute Wages (W), Dividends (Q), Interests (I), and exemption(e).
1879
- For accounting for rent and trust income, one can easily add a column
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
1880
1943
  to the Wages and Contributions file and add yearly amount to Q_n + I_n below.
1881
1944
  """
1882
1945
  J_n = np.zeros(self.N_n)
@@ -1887,17 +1950,17 @@ class Plan(object):
1887
1950
  status -= 1
1888
1951
 
1889
1952
  Gmax = tx.niitThreshold[status]
1890
- if self.MAGI_n[n] > Gmax:
1891
- J_n[n] = tx.niitRate * min(self.MAGI_n[n] - Gmax, self.I_n[n] + self.Q_n[n])
1953
+ if MAGI_n[n] > Gmax:
1954
+ J_n[n] = tx.niitRate * min(MAGI_n[n] - Gmax, I_n[n] + Q_n[n])
1892
1955
 
1893
1956
  return J_n
1894
1957
 
1895
- def _estimateMedicare(self, x=None, withMedicare=True):
1958
+ def _computeNLstuff(self, x, optimizeMedicare):
1896
1959
  """
1897
1960
  Compute MAGI, Medicare costs, long-term capital gain tax rate, and
1898
1961
  net investment income tax (NIIT).
1899
1962
  """
1900
- if x is None or withMedicare is False:
1963
+ if x is None:
1901
1964
  self.MAGI_n = np.zeros(self.N_n)
1902
1965
  self.J_n = np.zeros(self.N_n)
1903
1966
  self.M_n = np.zeros(self.N_n)
@@ -1906,9 +1969,11 @@ class Plan(object):
1906
1969
 
1907
1970
  self._aggregateResults(x, short=True)
1908
1971
 
1909
- self.J_n = self._computeNIIT()
1910
- self.M_n = tx.mediCosts(self.yobs, self.horizons, self.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)
1911
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)
1912
1977
 
1913
1978
  return None
1914
1979
 
@@ -1923,18 +1988,19 @@ class Plan(object):
1923
1988
  Nk = self.N_k
1924
1989
  Nn = self.N_n
1925
1990
  Nt = self.N_t
1926
- # Nz = self.N_z
1991
+ # Nzx = self.N_zx
1927
1992
  n_d = self.n_d
1928
1993
 
1929
1994
  Cb = self.C["b"]
1930
1995
  Cd = self.C["d"]
1931
1996
  Ce = self.C["e"]
1932
- CF = self.C["F"]
1997
+ Cf = self.C["f"]
1933
1998
  Cg = self.C["g"]
1999
+ Cm = self.C["m"]
1934
2000
  Cs = self.C["s"]
1935
2001
  Cw = self.C["w"]
1936
2002
  Cx = self.C["x"]
1937
- Cz = self.C["z"]
2003
+ Czx = self.C["zx"]
1938
2004
 
1939
2005
  x = u.roundCents(x)
1940
2006
 
@@ -1948,26 +2014,28 @@ class Plan(object):
1948
2014
  self.d_in = np.array(x[Cd:Ce])
1949
2015
  self.d_in = self.d_in.reshape((Ni, Nn))
1950
2016
 
1951
- self.e_n = np.array(x[Ce:CF])
2017
+ self.e_n = np.array(x[Ce:Cf])
2018
+
2019
+ self.f_tn = np.array(x[Cf:Cg])
2020
+ self.f_tn = self.f_tn.reshape((Nt, Nn))
1952
2021
 
1953
- self.F_tn = np.array(x[CF:Cg])
1954
- self.F_tn = self.F_tn.reshape((Nt, Nn))
2022
+ self.g_n = np.array(x[Cg:Cm])
1955
2023
 
1956
- self.g_n = np.array(x[Cg:Cs])
2024
+ self.m_n = np.array(x[Cm:Cs])
1957
2025
 
1958
2026
  self.s_n = np.array(x[Cs:Cw])
1959
2027
 
1960
2028
  self.w_ijn = np.array(x[Cw:Cx])
1961
2029
  self.w_ijn = self.w_ijn.reshape((Ni, Nj, Nn))
1962
2030
 
1963
- self.x_in = np.array(x[Cx:Cz])
2031
+ self.x_in = np.array(x[Cx:Czx])
1964
2032
  self.x_in = self.x_in.reshape((Ni, Nn))
1965
2033
 
1966
- # self.z_inz = np.array(x[Cz:])
1967
- # 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))
1968
2036
  # print(self.z_inz)
1969
2037
 
1970
- self.G_n = np.sum(self.F_tn, axis=0)
2038
+ self.G_n = np.sum(self.f_tn, axis=0)
1971
2039
 
1972
2040
  tau_0 = np.array(self.tau_kn[0, :])
1973
2041
  tau_0[tau_0 < 0] = 0
@@ -1976,10 +2044,10 @@ class Plan(object):
1976
2044
  self.Q_n = np.sum(
1977
2045
  (
1978
2046
  self.mu
1979
- * (self.b_ijn[:, 0, :-1] - self.w_ijn[:, 0, :] + self.d_in[:, :] + 0.5 * self.kappa_ijn[:, 0, :Nn])
2047
+ * (self.b_ijn[:, 0, :Nn] - self.w_ijn[:, 0, :] + self.d_in[:, :] + 0.5 * self.kappa_ijn[:, 0, :Nn])
1980
2048
  + tau_0prev * self.w_ijn[:, 0, :]
1981
2049
  )
1982
- * self.alpha_ijkn[:, 0, 0, :-1],
2050
+ * self.alpha_ijkn[:, 0, 0, :Nn],
1983
2051
  axis=0,
1984
2052
  )
1985
2053
  self.U_n = self.psi_n * self.Q_n
@@ -1987,14 +2055,14 @@ class Plan(object):
1987
2055
  self.MAGI_n = self.G_n + self.e_n + self.Q_n
1988
2056
 
1989
2057
  I_in = ((self.b_ijn[:, 0, :-1] + self.d_in - self.w_ijn[:, 0, :])
1990
- * np.sum(self.alpha_ijkn[:, 0, 1:, :-1] * self.tau_kn[1:, :], axis=1))
2058
+ * np.sum(self.alpha_ijkn[:, 0, 1:, :Nn] * self.tau_kn[1:, :], axis=1))
1991
2059
  self.I_n = np.sum(I_in, axis=0)
1992
2060
 
1993
2061
  # Stop after building minimu required for self-consistent loop.
1994
2062
  if short:
1995
2063
  return
1996
2064
 
1997
- self.T_tn = self.F_tn * self.theta_tn
2065
+ self.T_tn = self.f_tn * self.theta_tn
1998
2066
  self.T_n = np.sum(self.T_tn, axis=0)
1999
2067
  self.P_n = np.zeros(Nn)
2000
2068
  # Add early withdrawal penalty if any.
@@ -2170,8 +2238,8 @@ class Plan(object):
2170
2238
  dic[" Total net investment income tax paid"] = f"{u.d(taxPaidNow)}"
2171
2239
  dic["[Total net investment income tax paid]"] = f"{u.d(taxPaid)}"
2172
2240
 
2173
- taxPaid = np.sum(self.M_n, axis=0)
2174
- taxPaidNow = np.sum(self.M_n / self.gamma_n[:-1], axis=0)
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)
2175
2243
  dic[" Total Medicare premiums paid"] = f"{u.d(taxPaidNow)}"
2176
2244
  dic["[Total Medicare premiums paid]"] = f"{u.d(taxPaid)}"
2177
2245
 
@@ -2313,7 +2381,7 @@ class Plan(object):
2313
2381
  The value parameter can be set to *nominal* or *today*, overriding
2314
2382
  the default behavior of setDefaultPlots().
2315
2383
  """
2316
- value = self._checkValue(value)
2384
+ value = self._checkValueType(value)
2317
2385
  title = self._name + "\nNet Available Spending"
2318
2386
  if tag:
2319
2387
  title += " - " + tag
@@ -2338,7 +2406,7 @@ class Plan(object):
2338
2406
  The value parameter can be set to *nominal* or *today*, overriding
2339
2407
  the default behavior of setDefaultPlots().
2340
2408
  """
2341
- value = self._checkValue(value)
2409
+ value = self._checkValueType(value)
2342
2410
  figures = self._plotter.plot_asset_composition(self.year_n, self.inames, self.b_ijkn,
2343
2411
  self.gamma_n, value, self._name, tag)
2344
2412
  if figure:
@@ -2358,8 +2426,8 @@ class Plan(object):
2358
2426
  The value parameter can be set to *nominal* or *today*, overriding
2359
2427
  the default behavior of setDefaultPlots().
2360
2428
  """
2361
- value = self._checkValue(value)
2362
- 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)
2363
2431
  title = self._name + "\nTaxable Ordinary Income vs. Tax Brackets"
2364
2432
  if tag:
2365
2433
  title += " - " + tag
@@ -2402,7 +2470,7 @@ class Plan(object):
2402
2470
  The value parameter can be set to *nominal* or *today*, overriding
2403
2471
  the default behavior of setDefaultPlots().
2404
2472
  """
2405
- value = self._checkValue(value)
2473
+ value = self._checkValueType(value)
2406
2474
  title = self._name + "\nSavings Balance"
2407
2475
  if tag:
2408
2476
  title += " - " + tag
@@ -2424,7 +2492,7 @@ class Plan(object):
2424
2492
  The value parameter can be set to *nominal* or *today*, overriding
2425
2493
  the default behavior of setDefaultPlots().
2426
2494
  """
2427
- value = self._checkValue(value)
2495
+ value = self._checkValueType(value)
2428
2496
  title = self._name + "\nRaw Income Sources"
2429
2497
  if tag:
2430
2498
  title += " - " + tag
@@ -2446,13 +2514,13 @@ class Plan(object):
2446
2514
  The value parameter can be set to *nominal* or *today*, overriding
2447
2515
  the default behavior of setDefaultPlots().
2448
2516
  """
2449
- value = self._checkValue(value)
2517
+ value = self._checkValueType(value)
2450
2518
  title = self._name + "\nFederal Income Tax"
2451
2519
  if tag:
2452
2520
  title += " - " + tag
2453
2521
  # All taxes: ordinary income, dividends, and NIIT.
2454
2522
  allTaxes = self.T_n + self.U_n + self.J_n
2455
- fig = self._plotter.plot_taxes(self.year_n, allTaxes, self.M_n, self.gamma_n,
2523
+ fig = self._plotter.plot_taxes(self.year_n, allTaxes, self.m_n + self.M_n, self.gamma_n,
2456
2524
  value, title, self.inames)
2457
2525
  if figure:
2458
2526
  return fig
@@ -2523,7 +2591,7 @@ class Plan(object):
2523
2591
  "net spending": self.g_n,
2524
2592
  "taxable ord. income": self.G_n,
2525
2593
  "taxable gains/divs": self.Q_n,
2526
- "Tax bills + Med.": self.T_n + self.U_n + self.M_n + self.J_n,
2594
+ "Tax bills + Med.": self.T_n + self.U_n + self.m_n + self.M_n + self.J_n,
2527
2595
  }
2528
2596
 
2529
2597
  fillsheet(ws, incomeDic, "currency")
@@ -2539,7 +2607,7 @@ class Plan(object):
2539
2607
  "all deposits": -np.sum(self.d_in, axis=0),
2540
2608
  "ord taxes": -self.T_n - self.J_n,
2541
2609
  "div taxes": -self.U_n,
2542
- "Medicare": -self.M_n,
2610
+ "Medicare": -self.m_n - self.M_n,
2543
2611
  }
2544
2612
  sname = "Cash Flow"
2545
2613
  ws = wb.create_sheet(sname)
@@ -2726,7 +2794,7 @@ def _saveWorkbook(wb, basename, overwrite, mylog):
2726
2794
  else:
2727
2795
  fname = basename
2728
2796
 
2729
- if overwrite is False and isfile(fname):
2797
+ if not overwrite and isfile(fname):
2730
2798
  mylog.print(f'File "{fname}" already exists.')
2731
2799
  key = input("Overwrite? [Ny] ")
2732
2800
  if key != "y":
owlplanner/progress.py CHANGED
@@ -3,7 +3,7 @@ A simple object to display progress.
3
3
 
4
4
  Copyright &copy; 2024 - Martin-D. Lacasse
5
5
 
6
- Disclaimers: This code is for educatonal purposes only and does not constitute financial advice.
6
+ Disclaimers: This code is for educational purposes only and does not constitute financial advice.
7
7
 
8
8
  """
9
9
 
owlplanner/rates.py CHANGED
@@ -23,7 +23,7 @@ to the last current data year.
23
23
 
24
24
  Copyright &copy; 2024 - Martin-D. Lacasse
25
25
 
26
- Disclaimers: This code is for educatonal purposes only and does not constitute financial advice.
26
+ Disclaimers: This code is for educational purposes only and does not constitute financial advice.
27
27
 
28
28
  """
29
29
 
@@ -32,7 +32,6 @@ import numpy as np
32
32
  import pandas as pd
33
33
  import os
34
34
  import sys
35
- from datetime import date
36
35
 
37
36
  from owlplanner import mylogging as log
38
37
  from owlplanner import utils as u
@@ -120,27 +119,6 @@ def getRatesDistributions(frm, to, mylog=None):
120
119
  return means, stdev, corr, covar
121
120
 
122
121
 
123
- def historicalValue(amount, year):
124
- """
125
- Return the deflated value of amount given in this year's dollars as
126
- valued at the beginning of the year specified.
127
- """
128
- thisyear = date.today().year
129
- if TO != thisyear - 1:
130
- raise RuntimeError(f"Rates file needs to be updated to be current to {thisyear}.")
131
- if year < FROM:
132
- raise ValueError(f"Only data from {FROM} is available.")
133
- if year > thisyear:
134
- raise ValueError(f"Year must be < {thisyear} for historical data.")
135
-
136
- span = thisyear - year
137
- ub = len(Inflation)
138
- for n in range(ub - span, ub):
139
- amount /= 1 + Inflation[n] / 100
140
-
141
- return amount
142
-
143
-
144
122
  class Rates(object):
145
123
  """
146
124
  Rates are stored in a 4-array in the following order:
owlplanner/tax2025.py CHANGED
@@ -12,7 +12,7 @@ Module to handle all tax calculations.
12
12
 
13
13
  Copyright &copy; 2024 - Martin-D. Lacasse
14
14
 
15
- Disclaimers: This code is for educatonal purposes only and does not constitute financial advice.
15
+ Disclaimers: This code is for educational purposes only and does not constitute financial advice.
16
16
 
17
17
  """
18
18
 
@@ -25,8 +25,8 @@ from datetime import date
25
25
 
26
26
  taxBracketNames = ["10%", "12/15%", "22/25%", "24/28%", "32/33%", "35%", "37/40%"]
27
27
 
28
- rates_TCJA = np.array([0.10, 0.12, 0.22, 0.24, 0.32, 0.35, 0.370])
29
- rates_nonTCJA = np.array([0.10, 0.15, 0.25, 0.28, 0.33, 0.35, 0.396])
28
+ rates_OBBBA = np.array([0.10, 0.12, 0.22, 0.24, 0.32, 0.35, 0.370])
29
+ rates_preTCJA = np.array([0.10, 0.15, 0.25, 0.28, 0.33, 0.35, 0.396])
30
30
 
31
31
  ###############################################################################
32
32
  # Start of section where rates need to be actualized every year.
@@ -34,7 +34,7 @@ rates_nonTCJA = np.array([0.10, 0.15, 0.25, 0.28, 0.33, 0.35, 0.396])
34
34
  # Single [0] and married filing jointly [1].
35
35
 
36
36
  # These are 2025 current.
37
- taxBrackets_TCJA = np.array(
37
+ taxBrackets_OBBBA = np.array(
38
38
  [
39
39
  [11925, 48475, 103350, 197300, 250525, 626350, 9999999],
40
40
  [23850, 96950, 206700, 394600, 501050, 751600, 9999999],
@@ -52,7 +52,7 @@ irmaaBrackets = np.array(
52
52
  # Following values are incremental IRMAA part B monthly fees.
53
53
  irmaaFees = 12 * np.array([185.00, 74.00, 111.00, 110.90, 111.00, 37.00])
54
54
 
55
- # Make projection for non-TCJA using 2017 to current year.
55
+ # Make projection for pre-TCJA using 2017 to current year.
56
56
  # taxBrackets_2017 = np.array(
57
57
  # [ [9325, 37950, 91900, 191650, 416700, 418400, 9999999],
58
58
  # [18650, 75900, 153100, 233350, 416700, 470700, 9999999],
@@ -63,7 +63,7 @@ irmaaFees = 12 * np.array([185.00, 74.00, 111.00, 110.90, 111.00, 37.00])
63
63
  # For 2025, I used a 30.5% adjustment from 2017, rounded to closest 50.
64
64
  #
65
65
  # These are speculated.
66
- taxBrackets_nonTCJA = np.array(
66
+ taxBrackets_preTCJA = np.array(
67
67
  [
68
68
  [12150, 49550, 119950, 250200, 544000, 546200, 9999999], # Single
69
69
  [24350, 99100, 199850, 304600, 543950, 614450, 9999999], # MFJ
@@ -71,11 +71,11 @@ taxBrackets_nonTCJA = np.array(
71
71
  )
72
72
 
73
73
  # These are 2025 current (adjusted for inflation).
74
- stdDeduction_TCJA = np.array([15000, 30000]) # Single, MFJ
74
+ stdDeduction_OBBBA = np.array([15750, 31500]) # Single, MFJ
75
75
  # These are speculated (adjusted for inflation).
76
- stdDeduction_nonTCJA = np.array([8300, 16600]) # Single, MFJ
76
+ stdDeduction_preTCJA = np.array([8300, 16600]) # Single, MFJ
77
77
 
78
- # These are current (adjusted for inflation).
78
+ # These are current (adjusted for inflation) per individual.
79
79
  extra65Deduction = np.array([2000, 1600]) # Single, MFJ
80
80
 
81
81
  # Thresholds for capital gains (adjusted for inflation).
@@ -90,11 +90,51 @@ capGainRates = np.array(
90
90
  niitThreshold = np.array([200000, 250000])
91
91
  niitRate = 0.038
92
92
 
93
+ # Thresholds for 65+ bonus for circumventing tax on social security.
94
+ bonusThreshold = np.array([75000, 150000])
95
+
93
96
  ###############################################################################
94
97
  # End of section where rates need to be actualized every year.
95
98
  ###############################################################################
96
99
 
97
100
 
101
+ def mediVals(yobs, horizons, gamma_n, Nn, Nq):
102
+ """
103
+ Return tuple (nm, L, C) of year index when Medicare starts and vectors L, and C
104
+ defining end points of constant piecewise linear functions representing IRMAA fees.
105
+ """
106
+ thisyear = date.today().year
107
+ assert Nq == len(irmaaFees), f"Inconsistent value of Nq: {Nq}."
108
+ assert Nq == len(irmaaBrackets[0]), "Inconsistent IRMAA brackets array."
109
+ Ni = len(yobs)
110
+ # What index year will Medicare start? 65 - age.
111
+ nm = 65 - (thisyear - yobs)
112
+ nm = np.min(nm)
113
+ # Has it already started?
114
+ nm = max(0, nm)
115
+ Nmed = Nn - nm
116
+
117
+ L = np.zeros((Nmed, Nq-1))
118
+ C = np.zeros((Nmed, Nq))
119
+
120
+ # Year starts at offset nm in the plan.
121
+ for nn in range(Nmed):
122
+ imed = 0
123
+ n = nm + nn
124
+ if thisyear + n - yobs[0] >= 65 and n < horizons[0]:
125
+ imed += 1
126
+ if Ni == 2 and thisyear + n - yobs[1] >= 65 and n < horizons[1]:
127
+ imed += 1
128
+ if imed:
129
+ status = 0 if Ni == 1 else 1 if n < horizons[0] and n < horizons[1] else 0
130
+ L[nn] = gamma_n[n] * irmaaBrackets[status][1:]
131
+ C[nn] = imed * gamma_n[n] * irmaaFees
132
+ else:
133
+ raise RuntimeError("mediVals: This should never happen.")
134
+
135
+ return nm, L, C
136
+
137
+
98
138
  def capitalGainTaxRate(Ni, magi_n, gamma_n, nd, Nn):
99
139
  """
100
140
  Return an array of decimal rates for capital gains.
@@ -105,7 +145,7 @@ def capitalGainTaxRate(Ni, magi_n, gamma_n, nd, Nn):
105
145
  cgRate_n = np.zeros(Nn)
106
146
 
107
147
  for n in range(Nn):
108
- if n == nd:
148
+ if status and n == nd:
109
149
  status -= 1
110
150
 
111
151
  if magi_n[n] > gamma_n[n] * capGainRates[status][1]:
@@ -140,11 +180,11 @@ def mediCosts(yobs, horizons, magi, prevmagi, gamma_n, Nn):
140
180
  return costs
141
181
 
142
182
 
143
- def taxParams(yobs, i_d, n_d, N_n, y_TCJA=2026):
183
+ def taxParams(yobs, i_d, n_d, N_n, gamma_n, MAGI_n, yOBBBA=2099):
144
184
  """
145
185
  Input is year of birth, index of shortest-lived individual,
146
186
  lifespan of shortest-lived individual, total number of years
147
- in the plan, and the year that TCJA might expire.
187
+ in the plan, and the year that preTCJA rates might come back.
148
188
 
149
189
  It returns 3 time series:
150
190
  1) Standard deductions at year n (sigma_n).
@@ -154,15 +194,15 @@ def taxParams(yobs, i_d, n_d, N_n, y_TCJA=2026):
154
194
  Returned values are not indexed for inflation.
155
195
  """
156
196
  # Compute the deltas in-place between brackets, starting from the end.
157
- deltaBrackets_TCJA = np.array(taxBrackets_TCJA)
158
- deltaBrackets_nonTCJA = np.array(taxBrackets_nonTCJA)
197
+ deltaBrackets_OBBBA = np.array(taxBrackets_OBBBA)
198
+ deltaBrackets_preTCJA = np.array(taxBrackets_preTCJA)
159
199
  for t in range(6, 0, -1):
160
200
  for i in range(2):
161
- deltaBrackets_TCJA[i, t] -= deltaBrackets_TCJA[i, t - 1]
162
- deltaBrackets_nonTCJA[i, t] -= deltaBrackets_nonTCJA[i, t - 1]
201
+ deltaBrackets_OBBBA[i, t] -= deltaBrackets_OBBBA[i, t - 1]
202
+ deltaBrackets_preTCJA[i, t] -= deltaBrackets_preTCJA[i, t - 1]
163
203
 
164
204
  # Prepare the 3 arrays to return - use transpose for easy slicing.
165
- sigma = np.zeros((N_n))
205
+ sigmaBar = np.zeros((N_n))
166
206
  Delta = np.zeros((N_n, 7))
167
207
  theta = np.zeros((N_n, 7))
168
208
 
@@ -176,51 +216,57 @@ def taxParams(yobs, i_d, n_d, N_n, y_TCJA=2026):
176
216
  souls.remove(i_d)
177
217
  filingStatus -= 1
178
218
 
179
- if thisyear + n < y_TCJA:
180
- sigma[n] = stdDeduction_TCJA[filingStatus]
181
- Delta[n, :] = deltaBrackets_TCJA[filingStatus, :]
219
+ if thisyear + n < yOBBBA:
220
+ sigmaBar[n] = stdDeduction_OBBBA[filingStatus] * gamma_n[n]
221
+ Delta[n, :] = deltaBrackets_OBBBA[filingStatus, :]
182
222
  else:
183
- sigma[n] = stdDeduction_nonTCJA[filingStatus]
184
- Delta[n, :] = deltaBrackets_nonTCJA[filingStatus, :]
223
+ sigmaBar[n] = stdDeduction_preTCJA[filingStatus] * gamma_n[n]
224
+ Delta[n, :] = deltaBrackets_preTCJA[filingStatus, :]
185
225
 
186
- # Add 65+ additional exemption(s).
226
+ # Add 65+ additional exemption(s) and "bonus" phasing out.
187
227
  for i in souls:
188
228
  if thisyear + n - yobs[i] >= 65:
189
- sigma[n] += extra65Deduction[filingStatus]
229
+ sigmaBar[n] += extra65Deduction[filingStatus] * gamma_n[n]
230
+ if thisyear + n <= 2028:
231
+ sigmaBar[n] += 6000 * max(0, 1 - 0.06*max(0, MAGI_n[n] - bonusThreshold[filingStatus]))
190
232
 
191
233
  # Fill in future tax rates for year n.
192
- if thisyear + n < y_TCJA:
193
- theta[n, :] = rates_TCJA[:]
234
+ if thisyear + n < yOBBBA:
235
+ theta[n, :] = rates_OBBBA[:]
194
236
  else:
195
- theta[n, :] = rates_nonTCJA[:]
237
+ theta[n, :] = rates_preTCJA[:]
196
238
 
197
239
  Delta = Delta.transpose()
198
240
  theta = theta.transpose()
199
241
 
200
- # Return series unadjusted for inflation, in STD order.
201
- return sigma, theta, Delta
242
+ # Return series unadjusted for inflation, except for sigmaBar, in STD order.
243
+ return sigmaBar, theta, Delta
202
244
 
203
245
 
204
- def taxBrackets(N_i, n_d, N_n, y_TCJA):
246
+ def taxBrackets(N_i, n_d, N_n, yOBBBA=2099):
205
247
  """
206
248
  Return dictionary containing future tax brackets
207
249
  unadjusted for inflation for plotting.
208
250
  """
209
251
  if not (0 < N_i <= 2):
210
252
  raise ValueError(f"Cannot process {N_i} individuals.")
253
+
211
254
  n_d = min(n_d, N_n)
212
255
  status = N_i - 1
213
256
 
214
- # Number of years left in TCJA from this year.
257
+ # Number of years left in OBBBA from this year.
215
258
  thisyear = date.today().year
216
- ytc = y_TCJA - thisyear
259
+ if yOBBBA < thisyear:
260
+ raise ValueError(f"Expiration year {yOBBBA} cannot be in the past.")
261
+
262
+ ytc = yOBBBA - thisyear
217
263
 
218
264
  data = {}
219
265
  for t in range(len(taxBracketNames) - 1):
220
266
  array = np.zeros(N_n)
221
267
  for n in range(N_n):
222
268
  stat = status if n < n_d else 0
223
- array[n] = taxBrackets_TCJA[stat][t] if n < ytc else taxBrackets_nonTCJA[stat][t]
269
+ array[n] = taxBrackets_OBBBA[stat][t] if n < ytc else taxBrackets_preTCJA[stat][t]
224
270
 
225
271
  data[taxBracketNames[t]] = array
226
272
 
@@ -280,12 +326,12 @@ def rho_in(yobs, N_n):
280
326
  thisyear = date.today().year
281
327
  for i in range(N_i):
282
328
  agenow = thisyear - yobs[i]
329
+ # Account for increase of RMD age between 2023 and 2032.
330
+ yrmd = 70 if yobs[i] < 1949 else 72 if 1949 <= yobs[i] <= 1950 else 73 if 1951 <= yobs[i] <= 1959 else 75
283
331
  for n in range(N_n):
284
- year = thisyear + n
285
332
  yage = agenow + n
286
333
 
287
- # Account for increase of RMD age between 2023 and 2032.
288
- if (yage < 73) or (year > 2032 and yage < 75):
334
+ if yage < yrmd:
289
335
  pass # rho[i][n] = 0
290
336
  else:
291
337
  rho[i][n] = 1.0 / rmdTable[yage - 72]
owlplanner/timelists.py CHANGED
@@ -12,7 +12,7 @@ Utility functions to read and check timelists.
12
12
 
13
13
  Copyright &copy; 2024 - Martin-D. Lacasse
14
14
 
15
- Disclaimers: This code is for educatonal purposes only and does not constitute financial advice.
15
+ Disclaimers: This code is for educational purposes only and does not constitute financial advice.
16
16
 
17
17
  """
18
18
 
owlplanner/utils.py CHANGED
@@ -6,7 +6,7 @@ This file contains functions for handling data.
6
6
 
7
7
  Copyright &copy; 2024 - Martin-D. Lacasse
8
8
 
9
- Disclaimers: This code is for educatonal purposes only and does not constitute financial advice.
9
+ Disclaimers: This code is for educational purposes only and does not constitute financial advice.
10
10
 
11
11
  """
12
12
 
owlplanner/version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "2025.07.01"
1
+ __version__ = "2025.08.01"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: owlplanner
3
- Version: 2025.7.1
3
+ Version: 2025.8.1
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
@@ -0,0 +1,22 @@
1
+ owlplanner/__init__.py,sha256=hJ2i4m2JpHPAKyQLjYOXpJzeEsgcTcKD-Vhm0AIjjWg,592
2
+ owlplanner/abcapi.py,sha256=rtg7d0UbftinokR9VlB49VUjDjzUq3ONnJbhMXVIrgo,6879
3
+ owlplanner/config.py,sha256=JJOtS6HyqA4qUHUZydSGG_RMWaCfVMRSOAfWbt4evMI,12461
4
+ owlplanner/mylogging.py,sha256=OVGeDFO7LIZG91R6HMpZBzjno-B8PH8Fo00Jw2Pdgqw,2558
5
+ owlplanner/plan.py,sha256=keKC9-XeQxza9--D7TaQfcZCLkGDy5yVV9D5pN25MHg,115211
6
+ owlplanner/progress.py,sha256=dUUlFmSAKUei36rUj2BINRY10f_YEUo_e23d0es6nrc,530
7
+ owlplanner/rates.py,sha256=9Nmo8AKsyi5PoCUrzhr06phkSlNTv-TXzj5iYFU76AY,14113
8
+ owlplanner/tax2025.py,sha256=2Jb_UbPT6ye-znRjA0nSaF8T8M17QW4MoRPDoW9XJ8s,10833
9
+ owlplanner/timelists.py,sha256=Q4kBt9kKAa5qxsvOe9wfyUtCQVgiwPmJXTwXUPRBBv8,4066
10
+ owlplanner/utils.py,sha256=afAjeO6Msf6Rn4jwz_7Ody9rHGWlBR7iQFqe1xzLNQc,2513
11
+ owlplanner/version.py,sha256=Or3KaHd8BXidAsAkScGwQRvtWIF5uufpsnfgNdR-Kpw,28
12
+ owlplanner/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
+ owlplanner/data/rates.csv,sha256=6fxg56BVVORrj9wJlUGFdGXKvOX5r7CSca8uhUbbuIU,3734
14
+ owlplanner/plotting/__init__.py,sha256=uhxqtUi0OI-QWNOO2LkXgQViW_9yM3rYb-204Wit974,250
15
+ owlplanner/plotting/base.py,sha256=UimGKpMTV-dVm3BX5Apr_Ltorc7dlDLCRPRQ3RF_v7c,2578
16
+ owlplanner/plotting/factory.py,sha256=EDopIAPQr9zHRgemObko18FlCeRNhNCoLNNFAOq-X6s,1030
17
+ owlplanner/plotting/matplotlib_backend.py,sha256=AOEkapD94U5hGNoS0EdbRoe8mgdMHH4oOvkXADZS914,17957
18
+ owlplanner/plotting/plotly_backend.py,sha256=AO33GxBHGYG5osir_H1iRRtGxdhs4AjfLV2d_xm35nY,33138
19
+ owlplanner-2025.8.1.dist-info/METADATA,sha256=9WRoRbNMdk2kxbT4-Coq912VJixT9rlF1Cv48Po4VJw,54044
20
+ owlplanner-2025.8.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
21
+ owlplanner-2025.8.1.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
22
+ owlplanner-2025.8.1.dist-info/RECORD,,
@@ -1,22 +0,0 @@
1
- owlplanner/__init__.py,sha256=hJ2i4m2JpHPAKyQLjYOXpJzeEsgcTcKD-Vhm0AIjjWg,592
2
- owlplanner/abcapi.py,sha256=8VCXS7nH_QZYxCUU3lwO0_UPR9Q5fuYQ6DHDLvHVLPg,6878
3
- owlplanner/config.py,sha256=-sSz37hwlnmI9_oXZn-R1rpmY0Vyk5L4X--NxGpgEMA,12446
4
- owlplanner/mylogging.py,sha256=RKUr-y-1XvKZzLMcfdtm4IM30LuRpJwb2qUeXmAWqME,2557
5
- owlplanner/plan.py,sha256=rivQ9lSFJx6Eahx83VyTOm6n4uxjbr6LiwiyChRhAnc,111133
6
- owlplanner/progress.py,sha256=2DOjOLo6Mo7m21wY-9iZhoUksAyi4VCbb6UL2RegNCw,529
7
- owlplanner/rates.py,sha256=7jXcuHbkJ3AVIeBYZdwme18rdYslIzCuT-c0cLzvKUU,14819
8
- owlplanner/tax2025.py,sha256=3uDJfKiSRFUp5WDcouAnTEQqEY7LnDKxqxDSKiTsOSQ,8927
9
- owlplanner/timelists.py,sha256=4pRumdoFlEmmh07wpGhDqauHl2doLG5JcRkvi41fvR4,4065
10
- owlplanner/utils.py,sha256=6Ky8ZKfNE9x--3znsZ8VZaT2PptDinszRxWsOCPanu8,2512
11
- owlplanner/version.py,sha256=J00-UKwWWUboYr10jyhzSLOwp2iWtGoC1DRPgnuINV0,28
12
- owlplanner/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
- owlplanner/data/rates.csv,sha256=6fxg56BVVORrj9wJlUGFdGXKvOX5r7CSca8uhUbbuIU,3734
14
- owlplanner/plotting/__init__.py,sha256=uhxqtUi0OI-QWNOO2LkXgQViW_9yM3rYb-204Wit974,250
15
- owlplanner/plotting/base.py,sha256=UimGKpMTV-dVm3BX5Apr_Ltorc7dlDLCRPRQ3RF_v7c,2578
16
- owlplanner/plotting/factory.py,sha256=EDopIAPQr9zHRgemObko18FlCeRNhNCoLNNFAOq-X6s,1030
17
- owlplanner/plotting/matplotlib_backend.py,sha256=AOEkapD94U5hGNoS0EdbRoe8mgdMHH4oOvkXADZS914,17957
18
- owlplanner/plotting/plotly_backend.py,sha256=AO33GxBHGYG5osir_H1iRRtGxdhs4AjfLV2d_xm35nY,33138
19
- owlplanner-2025.7.1.dist-info/METADATA,sha256=_T6vNe7aESAIt668fK9IVb0VDEv5P3Z16bYPyTvr9QY,54044
20
- owlplanner-2025.7.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
21
- owlplanner-2025.7.1.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
22
- owlplanner-2025.7.1.dist-info/RECORD,,