owlplanner 2025.5.15__py3-none-any.whl → 2025.5.28__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/config.py CHANGED
@@ -65,7 +65,7 @@ def saveConfig(myplan, file, mylog):
65
65
  diconf["Rates Selection"] = {
66
66
  "Heirs rate on tax-deferred estate": float(100 * myplan.nu),
67
67
  "Long-term capital gain tax rate": float(100 * myplan.psi),
68
- "Dividend tax rate": float(100 * myplan.mu),
68
+ "Dividend rate": float(100 * myplan.mu),
69
69
  "TCJA expiration year": myplan.yTCJA,
70
70
  "Method": myplan.rateMethod,
71
71
  }
@@ -182,20 +182,19 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
182
182
  # status = diconf['Basic Info']['Status']
183
183
  yobs = diconf["Basic Info"]["Birth year"]
184
184
  expectancy = diconf["Basic Info"]["Life expectancy"]
185
- startDate = diconf["Basic Info"].get("Start date", "today")
186
185
  icount = len(yobs)
187
186
  s = ["", "s"][icount - 1]
188
187
  mylog.vprint(f"Plan for {icount} individual{s}: {inames}.")
189
- p = plan.Plan(inames, yobs, expectancy, name, startDate=startDate, verbose=True, logstreams=logstreams)
188
+ p = plan.Plan(inames, yobs, expectancy, name, verbose=True, logstreams=logstreams)
190
189
  p._description = diconf.get("Description", "")
191
190
 
192
191
  # Assets.
192
+ startDate = diconf["Basic Info"].get("Start date", "today")
193
193
  balances = {}
194
194
  for acc in accountTypes:
195
195
  balances[acc] = diconf["Assets"][f"{acc} savings balances"]
196
- p.setAccountBalances(
197
- taxable=balances["taxable"], taxDeferred=balances["tax-deferred"], taxFree=balances["tax-free"]
198
- )
196
+ p.setAccountBalances(taxable=balances["taxable"], taxDeferred=balances["tax-deferred"],
197
+ taxFree=balances["tax-free"], startDate=startDate)
199
198
  if icount == 2:
200
199
  phi_j = diconf["Assets"]["Beneficiary fractions"]
201
200
  p.setBeneficiaryFractions(phi_j)
@@ -227,7 +226,7 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
227
226
  p.setPension(pensionAmounts, pensionAges, pensionIsIndexed)
228
227
 
229
228
  # Rates Selection.
230
- p.setDividendRate(float(diconf["Rates Selection"]["Dividend tax rate"]))
229
+ p.setDividendRate(float(diconf["Rates Selection"].get("Dividend rate", 1.8))) # Fix for mod.
231
230
  p.setLongTermCapitalTaxRate(float(diconf["Rates Selection"]["Long-term capital gain tax rate"]))
232
231
  p.setHeirsTaxRate(float(diconf["Rates Selection"]["Heirs rate on tax-deferred estate"]))
233
232
  p.yTCJA = int(diconf["Rates Selection"]["TCJA expiration year"])
owlplanner/plan.py CHANGED
@@ -211,7 +211,7 @@ class Plan(object):
211
211
  This is the main class of the Owl Project.
212
212
  """
213
213
 
214
- def __init__(self, inames, yobs, expectancy, name, *, startDate=None, verbose=False, logstreams=None):
214
+ def __init__(self, inames, yobs, expectancy, name, *, verbose=False, logstreams=None):
215
215
  """
216
216
  Constructor requires three lists: the first
217
217
  one contains the name(s) of the individual(s),
@@ -242,7 +242,9 @@ class Plan(object):
242
242
  self.defaultPlots = "nominal"
243
243
  self.defaultSolver = "HiGHS"
244
244
  self._plotterName = None
245
- self.setPlotBackend("matplotlib")
245
+ # Pick a default plotting backend here.
246
+ # self.setPlotBackend("matplotlib")
247
+ self.setPlotBackend("plotly")
246
248
 
247
249
  self.N_i = len(yobs)
248
250
  if not (0 <= self.N_i <= 2):
@@ -281,9 +283,9 @@ class Plan(object):
281
283
 
282
284
  # Default parameters:
283
285
  self.psi = 0.15 # Long-term income tax rate on capital gains (decimal)
284
- self.chi = 0.6 # Survivor fraction
285
- self.mu = 0.02 # Dividend rate (decimal)
286
- self.nu = 0.30 # Heirs tax rate (decimal)
286
+ self.chi = 0.6 # Survivor fraction
287
+ self.mu = 0.018 # Dividend rate (decimal)
288
+ self.nu = 0.30 # Heirs tax rate (decimal)
287
289
  self.eta = (self.N_i - 1) / 2 # Spousal deposit ratio (0 or .5)
288
290
  self.phi_j = np.array([1, 1, 1]) # Fractions left to other spouse at death
289
291
  self.smileDip = 15 # Percent to reduce smile profile
@@ -307,6 +309,10 @@ class Plan(object):
307
309
  # Previous 3 years for Medicare.
308
310
  self.prevMAGI = np.zeros((3))
309
311
 
312
+ # Init previous balance to none.
313
+ self.beta_ij = None
314
+ self.startDate = None
315
+
310
316
  # Default slack on profile.
311
317
  self.lambdha = 0
312
318
 
@@ -320,9 +326,6 @@ class Plan(object):
320
326
  # Prepare RMD time series.
321
327
  self.rho_in = tx.rho_in(self.yobs, self.N_n)
322
328
 
323
- # If none was given, default is to begin plan on today's date.
324
- self._setStartingDate(startDate)
325
-
326
329
  self._buildOffsetMap()
327
330
 
328
331
  # Initialize guardrails to ensure proper configuration.
@@ -363,7 +366,7 @@ class Plan(object):
363
366
  def _setStartingDate(self, mydate):
364
367
  """
365
368
  Set the date when the plan starts in the current year.
366
- This is for reproducibility purposes.
369
+ This is mostly for reproducibility purposes and back projecting known balances to Jan 1st.
367
370
  String format of mydate is 'month/day'.
368
371
  """
369
372
  import calendar
@@ -463,7 +466,7 @@ class Plan(object):
463
466
 
464
467
  def setDividendRate(self, mu):
465
468
  """
466
- Set dividend tax rate. Rate is in percent. Default 2%.
469
+ Set dividend tax rate. Rate is in percent. Default 1.8%.
467
470
  """
468
471
  if not (0 <= mu <= 100):
469
472
  raise ValueError("Rate must be between 0 and 100.")
@@ -549,9 +552,6 @@ class Plan(object):
549
552
  ns = max(0, self.yobs[i] + ages[i] - thisyear)
550
553
  nd = self.horizons[i]
551
554
  self.pi_in[i, ns:nd] = amounts[i]
552
- # Only include remaining part of current year.
553
- if ns == 0:
554
- self.pi_in[i, 0] *= self.yearFracLeft
555
555
 
556
556
  self.pensionAmounts = np.array(amounts)
557
557
  self.pensionAges = np.array(ages, dtype=np.int32)
@@ -583,9 +583,6 @@ class Plan(object):
583
583
  ns = max(0, self.yobs[i] + ages[i] - thisyear)
584
584
  nd = self.horizons[i]
585
585
  self.zeta_in[i, ns:nd] = amounts[i]
586
- # Only include remaining part of current year.
587
- if ns == 0:
588
- self.zeta_in[i, 0] *= self.yearFracLeft
589
586
 
590
587
  if self.N_i == 2:
591
588
  # Approximate calculation for spousal benefit (only valid at FRA).
@@ -618,8 +615,6 @@ class Plan(object):
618
615
  self.mylog.vprint("Securing", u.pc(self.chi, f=0), "of spending amount for surviving spouse.")
619
616
 
620
617
  self.xi_n = _genXi_n(profile, self.chi, self.n_d, self.N_n, dip, increase, delay)
621
- # Account for time elapsed in the current year.
622
- self.xi_n[0] *= self.yearFracLeft
623
618
 
624
619
  self.spendingProfile = profile
625
620
  self.smileDip = dip
@@ -654,9 +649,6 @@ class Plan(object):
654
649
  self.tau_kn = dr.genSeries(self.N_n).transpose()
655
650
  self.mylog.vprint(f"Generating rate series of {len(self.tau_kn[0])} years using {method} method.")
656
651
 
657
- # Account for how late we are now in the first year and reduce rate accordingly.
658
- self.tau_kn[:, 0] *= self.yearFracLeft
659
-
660
652
  # Once rates are selected, (re)build cumulative inflation multipliers.
661
653
  self.gamma_n = _genGamma_n(self.tau_kn)
662
654
  self._adjustedParameters = False
@@ -706,7 +698,7 @@ class Plan(object):
706
698
 
707
699
  return amount * self.gamma_n[span]
708
700
 
709
- def setAccountBalances(self, *, taxable, taxDeferred, taxFree, units="k"):
701
+ def setAccountBalances(self, *, taxable, taxDeferred, taxFree, startDate=None, units="k"):
710
702
  """
711
703
  Three lists containing the balance of all assets in each category for
712
704
  each spouse. For single individuals, these lists will contain only
@@ -725,11 +717,15 @@ class Plan(object):
725
717
  taxDeferred = u.rescale(taxDeferred, fac)
726
718
  taxFree = u.rescale(taxFree, fac)
727
719
 
728
- self.b_ji = np.zeros((self.N_j, self.N_i))
729
- self.b_ji[0][:] = taxable
730
- self.b_ji[1][:] = taxDeferred
731
- self.b_ji[2][:] = taxFree
732
- self.beta_ij = self.b_ji.transpose()
720
+ self.bet_ji = np.zeros((self.N_j, self.N_i))
721
+ self.bet_ji[0][:] = taxable
722
+ self.bet_ji[1][:] = taxDeferred
723
+ self.bet_ji[2][:] = taxFree
724
+ self.beta_ij = self.bet_ji.transpose()
725
+
726
+ # If none was given, default is to begin plan on today's date.
727
+ self._setStartingDate(startDate)
728
+
733
729
  self.caseStatus = "modified"
734
730
 
735
731
  self.mylog.vprint("Taxable balances:", *[u.d(taxable[i]) for i in range(self.N_i)])
@@ -929,10 +925,6 @@ class Plan(object):
929
925
  self.myRothX_in[i, :h] = self.timeLists[iname]["Roth conv"].iloc[:h]
930
926
  self.Lambda_in[i, :h] = self.timeLists[iname]["big-ticket items"].iloc[:h]
931
927
 
932
- # In 1st year, reduce wages and contributions depending on starting date.
933
- self.omega_in[:, 0] *= self.yearFracLeft
934
- self.kappa_ijn[:, :, 0] *= self.yearFracLeft
935
-
936
928
  self.caseStatus = "modified"
937
929
 
938
930
  return self.timeLists
@@ -1215,13 +1207,17 @@ class Plan(object):
1215
1207
  spending = options["netSpending"]
1216
1208
  if not isinstance(spending, (int, float)):
1217
1209
  raise ValueError(f"Desired spending provided {spending} is not a number.")
1218
- spending *= self.optionsUnits * self.yearFracLeft
1210
+ spending *= self.optionsUnits
1219
1211
  self.B.setRange(_q1(self.C["g"], 0, self.N_n), spending, spending)
1220
1212
 
1221
1213
  def _add_initial_balances(self):
1214
+ # Back project balances to the beginning of the year.
1215
+ yearSpent = 1 - self.yearFracLeft
1216
+
1222
1217
  for i in range(self.N_i):
1223
1218
  for j in range(self.N_j):
1224
- rhs = self.beta_ij[i, j]
1219
+ backTau = 1 - yearSpent * np.sum(self.tau_kn[:, 0] * self.alpha_ijkn[i, j, :, 0])
1220
+ rhs = self.beta_ij[i, j] * backTau
1225
1221
  self.B.setRange(_q3(self.C["b"], i, j, 0, self.N_i, self.N_j, self.N_n + 1), rhs, rhs)
1226
1222
 
1227
1223
  def _add_surplus_deposit_linking(self):
@@ -2063,43 +2059,43 @@ class Plan(object):
2063
2059
  # Results
2064
2060
  dic["Plan name"] = self._name
2065
2061
  dic["Net yearly spending basis"] = u.d(self.g_n[0] / self.xi_n[0])
2066
- dic[f"Net spending for year {now}"] = u.d(self.g_n[0] / self.yearFracLeft)
2067
- dic[f"Net spending remaining in year {now}"] = u.d(self.g_n[0])
2062
+ dic[f"Net spending for year {now}"] = u.d(self.g_n[0])
2063
+ dic[f"Net spending remaining in year {now}"] = u.d(self.g_n[0] * self.yearFracLeft)
2068
2064
 
2069
2065
  totSpending = np.sum(self.g_n, axis=0)
2070
2066
  totSpendingNow = np.sum(self.g_n / self.gamma_n[:-1], axis=0)
2071
- dic["Total net spending"] = f"{u.d(totSpendingNow)}"
2067
+ dic[" Total net spending"] = f"{u.d(totSpendingNow)}"
2072
2068
  dic["[Total net spending]"] = f"{u.d(totSpending)}"
2073
2069
 
2074
2070
  totRoth = np.sum(self.x_in, axis=(0, 1))
2075
2071
  totRothNow = np.sum(np.sum(self.x_in, axis=0) / self.gamma_n[:-1], axis=0)
2076
- dic["Total Roth conversions"] = f"{u.d(totRothNow)}"
2072
+ dic[" Total Roth conversions"] = f"{u.d(totRothNow)}"
2077
2073
  dic["[Total Roth conversions]"] = f"{u.d(totRoth)}"
2078
2074
 
2079
2075
  taxPaid = np.sum(self.T_n, axis=0)
2080
2076
  taxPaidNow = np.sum(self.T_n / self.gamma_n[:-1], axis=0)
2081
- dic["Total income tax paid on ordinary income"] = f"{u.d(taxPaidNow)}"
2082
- dic["[Total income tax paid on ordinary income]"] = f"{u.d(taxPaid)}"
2077
+ dic[" Total tax paid on ordinary income"] = f"{u.d(taxPaidNow)}"
2078
+ dic["[Total tax paid on ordinary income]"] = f"{u.d(taxPaid)}"
2083
2079
  for t in range(self.N_t):
2084
2080
  taxPaid = np.sum(self.T_tn[t], axis=0)
2085
2081
  taxPaidNow = np.sum(self.T_tn[t] / self.gamma_n[:-1], axis=0)
2086
2082
  tname = tx.taxBracketNames[t]
2087
- dic[f"-- Subtotal in tax bracket {tname}"] = f"{u.d(taxPaidNow)}"
2088
- dic[f"-- [Subtotal in tax bracket {tname}]"] = f"{u.d(taxPaid)}"
2083
+ dic[f"» Subtotal in tax bracket {tname}"] = f"{u.d(taxPaidNow)}"
2084
+ dic[f"» [Subtotal in tax bracket {tname}]"] = f"{u.d(taxPaid)}"
2089
2085
 
2090
2086
  penaltyPaid = np.sum(self.P_n, axis=0)
2091
2087
  penaltyPaidNow = np.sum(self.P_n / self.gamma_n[:-1], axis=0)
2092
- dic["-- Subtotal in early withdrawal penalty"] = f"{u.d(penaltyPaidNow)}"
2093
- dic["-- [Subtotal in early withdrawal penalty]"] = f"{u.d(penaltyPaid)}"
2088
+ dic["» Subtotal in early withdrawal penalty"] = f"{u.d(penaltyPaidNow)}"
2089
+ dic["» [Subtotal in early withdrawal penalty]"] = f"{u.d(penaltyPaid)}"
2094
2090
 
2095
2091
  taxPaid = np.sum(self.U_n, axis=0)
2096
2092
  taxPaidNow = np.sum(self.U_n / self.gamma_n[:-1], axis=0)
2097
- dic["Total tax paid on gains and dividends"] = f"{u.d(taxPaidNow)}"
2093
+ dic[" Total tax paid on gains and dividends"] = f"{u.d(taxPaidNow)}"
2098
2094
  dic["[Total tax paid on gains and dividends]"] = f"{u.d(taxPaid)}"
2099
2095
 
2100
2096
  taxPaid = np.sum(self.M_n, axis=0)
2101
2097
  taxPaidNow = np.sum(self.M_n / self.gamma_n[:-1], axis=0)
2102
- dic["Total Medicare premiums paid"] = f"{u.d(taxPaidNow)}"
2098
+ dic[" Total Medicare premiums paid"] = f"{u.d(taxPaidNow)}"
2103
2099
  dic["[Total Medicare premiums paid]"] = f"{u.d(taxPaid)}"
2104
2100
 
2105
2101
  if self.N_i == 2 and self.n_d < self.N_n:
@@ -2107,50 +2103,51 @@ class Plan(object):
2107
2103
  p_j[1] *= 1 - self.nu
2108
2104
  nx = self.n_d - 1
2109
2105
  ynx = self.year_n[nx]
2106
+ ynxNow = 1./self.gamma_n[nx + 1]
2110
2107
  totOthers = np.sum(p_j)
2111
- totOthersNow = totOthers / self.gamma_n[nx + 1]
2112
2108
  q_j = self.partialEstate_j * self.phi_j
2113
2109
  totSpousal = np.sum(q_j)
2114
- totSpousalNow = totSpousal / self.gamma_n[nx + 1]
2115
2110
  iname_s = self.inames[self.i_s]
2116
2111
  iname_d = self.inames[self.i_d]
2117
- dic[f"Sum of spousal transfer to {iname_s} in year {ynx}"] = (f"{u.d(totSpousalNow)}")
2118
- dic[f"[Sum of spousal transfer to {iname_s} in year {ynx}]"] = (
2119
- f"{u.d(totSpousal)}")
2120
- dic[f"-- [Spousal transfer to {iname_s} in year {ynx} - taxable]"] = (
2121
- f"{u.d(q_j[0])}")
2122
- dic[f"-- [Spousal transfer to {iname_s} in year {ynx} - tax-def]"] = (
2123
- f"{u.d(q_j[1])}")
2124
- dic[f"-- [Spousal transfer to {iname_s} in year {ynx} - tax-free]"] = (
2125
- f"{u.d(q_j[2])}")
2126
-
2127
- dic[f"Sum of post-tax non-spousal bequests from {iname_d} in year {ynx}"] = (
2128
- f"{u.d(totOthersNow)}")
2129
- dic[f"[Sum of post-tax non-spousal bequests from {iname_d} in year {ynx}]"] = (
2130
- f"{u.d(totOthers)}")
2131
- dic[f"-- [Post-tax non-spousal bequests from {iname_d} in year {ynx} - taxable]"] = (
2132
- f"{u.d(p_j[0])}")
2133
- dic[f"-- [Post-tax non-spousal bequests from {iname_d} in year {ynx} - tax-def]"] = (
2134
- f"{u.d(p_j[1])}")
2135
- dic[f"-- [Post-tax non-spousal bequests from {iname_d} in year {ynx} - tax-free]"] = (
2136
- f"{u.d(p_j[2])}")
2112
+ dic["Year of partial bequest"] = (f"{ynx}")
2113
+ dic[f" Sum of spousal transfer to {iname_s}"] = (f"{u.d(ynxNow*totSpousal)}")
2114
+ dic[f"[Sum of spousal transfer to {iname_s}]"] = (f"{u.d(totSpousal)}")
2115
+ dic[f"» Spousal transfer to {iname_s} - taxable"] = (f"{u.d(ynxNow*q_j[0])}")
2116
+ dic[f"» [Spousal transfer to {iname_s} - taxable]"] = (f"{u.d(q_j[0])}")
2117
+ dic[f"» Spousal transfer to {iname_s} - tax-def"] = (f"{u.d(ynxNow*q_j[1])}")
2118
+ dic[f"» [Spousal transfer to {iname_s} - tax-def]"] = (f"{u.d(q_j[1])}")
2119
+ dic[f"» Spousal transfer to {iname_s} - tax-free"] = (f"{u.d(ynxNow*q_j[2])}")
2120
+ dic[f"» [Spousal transfer to {iname_s} - tax-free]"] = (f"{u.d(q_j[2])}")
2121
+
2122
+ dic[f" Sum of post-tax non-spousal bequest from {iname_d}"] = (f"{u.d(ynxNow*totOthers)}")
2123
+ dic[f"[Sum of post-tax non-spousal bequest from {iname_d}]"] = (f"{u.d(totOthers)}")
2124
+ dic[f"» Post-tax non-spousal bequest from {iname_d} - taxable"] = (f"{u.d(ynxNow*p_j[0])}")
2125
+ dic[f"» [Post-tax non-spousal bequest from {iname_d} - taxable]"] = (f"{u.d(p_j[0])}")
2126
+ dic[f"» Post-tax non-spousal bequest from {iname_d} - tax-def"] = (f"{u.d(ynxNow*p_j[1])}")
2127
+ dic[f"» [Post-tax non-spousal bequest from {iname_d} - tax-def]"] = (f"{u.d(p_j[1])}")
2128
+ dic[f"» Post-tax non-spousal bequest from {iname_d} - tax-free"] = (f"{u.d(ynxNow*p_j[2])}")
2129
+ dic[f"» [Post-tax non-spousal bequest from {iname_d} - tax-free]"] = (f"{u.d(p_j[2])}")
2137
2130
 
2138
2131
  estate = np.sum(self.b_ijn[:, :, self.N_n], axis=0)
2139
2132
  estate[1] *= 1 - self.nu
2140
- lastyear = self.year_n[-1]
2133
+ endyear = self.year_n[-1]
2134
+ lyNow = 1./self.gamma_n[-1]
2141
2135
  totEstate = np.sum(estate)
2142
- totEstateNow = totEstate / self.gamma_n[-1]
2143
- dic[f"Total estate value at the end of {lastyear}"] = (f"{u.d(totEstateNow)}")
2144
- dic[f"[Total estate value at the end of {lastyear}]"] = (f"{u.d(totEstate)}")
2145
- dic[f"-- [Post-tax account value at the end of {lastyear} - taxable]"] = (f"{u.d(estate[0])}")
2146
- dic[f"-- [Post-tax account value at the end of {lastyear} - tax-def]"] = (f"{u.d(estate[1])}")
2147
- dic[f"-- [Post-tax account value at the end of {lastyear} - tax-free]"] = (f"{u.d(estate[2])}")
2136
+ dic["Year of final bequest"] = (f"{endyear}")
2137
+ dic[" Total value of final bequest"] = (f"{u.d(lyNow*totEstate)}")
2138
+ dic["[Total value of final bequest]"] = (f"{u.d(totEstate)}")
2139
+ dic["» Post-tax final bequest account value - taxable"] = (f"{u.d(lyNow*estate[0])}")
2140
+ dic["» [Post-tax final bequest account value - taxable]"] = (f"{u.d(estate[0])}")
2141
+ dic["» Post-tax final bequest account value - tax-def"] = (f"{u.d(lyNow*estate[1])}")
2142
+ dic["» [Post-tax final bequest account value - tax-def]"] = (f"{u.d(estate[1])}")
2143
+ dic["» Post-tax final bequest account value - tax-free"] = (f"{u.d(lyNow*estate[2])}")
2144
+ dic["» [Post-tax final bequest account value - tax-free]"] = (f"{u.d(estate[2])}")
2148
2145
 
2149
2146
  dic["Plan starting date"] = str(self.startDate)
2150
- dic[f"Cumulative inflation factor from start date to end of {lastyear}"] = (f"{self.gamma_n[-1]:.2f}")
2147
+ dic["Cumulative inflation factor at end of final year"] = (f"{self.gamma_n[-1]:.2f}")
2151
2148
  for i in range(self.N_i):
2152
- dic[f"{self.inames[i]:>12}'s {self.horizons[i]:02}-year life horizon"] = (
2153
- f"{now} -> {now + self.horizons[i] - 1}")
2149
+ dic[f"{self.inames[i]:>14}'s life horizon"] = (f"{now} -> {now + self.horizons[i] - 1}")
2150
+ dic[f"{self.inames[i]:>14}'s years planned"] = (f"{self.horizons[i]}")
2154
2151
 
2155
2152
  dic["Plan name"] = self._name
2156
2153
  dic["Number of decision variables"] = str(self.A.nvars)
@@ -2200,7 +2197,7 @@ class Plan(object):
2200
2197
  self.mylog.vprint("Warning: Rate method must be selected before plotting.")
2201
2198
  return None
2202
2199
 
2203
- fig = self._plotter.plot_rates(self._name, self.tau_kn, self.year_n, self.yearFracLeft,
2200
+ fig = self._plotter.plot_rates(self._name, self.tau_kn, self.year_n,
2204
2201
  self.N_k, self.rateMethod, self.rateFrm, self.rateTo, tag)
2205
2202
 
2206
2203
  if figure:
@@ -2252,9 +2249,9 @@ class Plan(object):
2252
2249
  return None
2253
2250
 
2254
2251
  @_checkCaseStatus
2255
- def showAssetDistribution(self, tag="", value=None, figure=False):
2252
+ def showAssetComposition(self, tag="", value=None, figure=False):
2256
2253
  """
2257
- Plot the distribution of each savings account in thousands of dollars
2254
+ Plot the composition of each savings account in thousands of dollars
2258
2255
  during the simulation time. This function will generate three
2259
2256
  graphs, one for taxable accounts, one the tax-deferred accounts,
2260
2257
  and one for tax-free accounts.
@@ -2265,8 +2262,8 @@ class Plan(object):
2265
2262
  the default behavior of setDefaultPlots().
2266
2263
  """
2267
2264
  value = self._checkValue(value)
2268
- figures = self._plotter.plot_asset_distribution(self.year_n, self.inames, self.b_ijkn,
2269
- self.gamma_n, value, self._name, tag)
2265
+ figures = self._plotter.plot_asset_composition(self.year_n, self.inames, self.b_ijkn,
2266
+ self.gamma_n, value, self._name, tag)
2270
2267
  if figure:
2271
2268
  return figures
2272
2269
 
@@ -2376,7 +2373,9 @@ class Plan(object):
2376
2373
  title = self._name + "\nIncome Tax"
2377
2374
  if tag:
2378
2375
  title += " - " + tag
2379
- fig = self._plotter.plot_taxes(self.year_n, self.T_n, self.M_n, self.gamma_n,
2376
+ # All taxes: ordinary income and dividends.
2377
+ allTaxes = self.T_n + self.U_n
2378
+ fig = self._plotter.plot_taxes(self.year_n, allTaxes, self.M_n, self.gamma_n,
2380
2379
  value, title, self.inames)
2381
2380
  if figure:
2382
2381
  return fig
@@ -2512,6 +2511,7 @@ class Plan(object):
2512
2511
  self.b_ijn[i][0][-1],
2513
2512
  0,
2514
2513
  0,
2514
+ 0,
2515
2515
  self.b_ijn[i][1][-1],
2516
2516
  0,
2517
2517
  0,
@@ -2520,7 +2520,6 @@ class Plan(object):
2520
2520
  self.b_ijn[i][2][-1],
2521
2521
  0,
2522
2522
  0,
2523
- 0,
2524
2523
  ]
2525
2524
  ws.append(lastRow)
2526
2525
  _formatSpreadsheet(ws, "currency")
@@ -25,7 +25,7 @@ class PlotBackend(ABC):
25
25
  pass
26
26
 
27
27
  @abstractmethod
28
- def plot_rates(self, name, tau_kn, year_n, year_frac_left, N_k, rate_method,
28
+ def plot_rates(self, name, tau_kn, year_n, N_k, rate_method,
29
29
  rate_frm=None, rate_to=None, tag=""):
30
30
  """Plot rate values used over the time horizon."""
31
31
  pass
@@ -51,8 +51,8 @@ class PlotBackend(ABC):
51
51
  pass
52
52
 
53
53
  @abstractmethod
54
- def plot_asset_distribution(self, year_n, inames, b_ijkn, gamma_n, value, name, tag):
55
- """Plot asset distribution over time."""
54
+ def plot_asset_composition(self, year_n, inames, b_ijkn, gamma_n, value, name, tag):
55
+ """Plot asset composition over time."""
56
56
  pass
57
57
 
58
58
  @abstractmethod
@@ -108,6 +108,9 @@ class MatplotlibBackend(PlotBackend):
108
108
  df.drop("partial", axis=1, inplace=True)
109
109
  means = df.mean(axis=0, numeric_only=True)
110
110
  medians = df.median(axis=0, numeric_only=True)
111
+ my[0] = my[1]
112
+
113
+ nfields = len(means)
111
114
 
112
115
  df /= 1000
113
116
  if len(df) > 0:
@@ -118,7 +121,7 @@ class MatplotlibBackend(PlotBackend):
118
121
  sbn.histplot(df, multiple="dodge", kde=True, ax=axes)
119
122
  legend = []
120
123
  # Don't know why but legend is reversed from df.
121
- for q in range(len(means) - 1, -1, -1):
124
+ for q in range(nfields - 1, -1, -1):
122
125
  dmedian = u.d(medians.iloc[q], latex=True)
123
126
  dmean = u.d(means.iloc[q], latex=True)
124
127
  legend.append(f"{my[q]}: $M$: {dmedian}, $\\bar{{x}}$: {dmean}")
@@ -126,13 +129,14 @@ class MatplotlibBackend(PlotBackend):
126
129
  plt.xlabel(f"{thisyear} $k")
127
130
  plt.title(objective)
128
131
  leads = [f"partial {my[0]}", f" final {my[1]}"]
129
- elif len(means) == 2:
132
+ leads = leads if nfields == 2 else leads[1:]
133
+ elif nfields == 2:
130
134
  # Show partial bequest and net spending as two separate histograms.
131
135
  fig, axes = plt.subplots(1, 2, figsize=(10, 5))
132
136
  cols = ["partial", objective]
133
137
  leads = [f"partial {my[0]}", objective]
134
- for q in range(2):
135
- sbn.histplot(df[cols[q]], kde=True, ax=axes[q])
138
+ for q, col in enumerate(cols):
139
+ sbn.histplot(df[col], kde=True, ax=axes[q])
136
140
  dmedian = u.d(medians.iloc[q], latex=True)
137
141
  dmean = u.d(means.iloc[q], latex=True)
138
142
  legend = [f"$M$: {dmedian}, $\\bar{{x}}$: {dmean}"]
@@ -154,7 +158,7 @@ class MatplotlibBackend(PlotBackend):
154
158
 
155
159
  plt.suptitle(title)
156
160
 
157
- for q in range(len(means)):
161
+ for q in range(nfields):
158
162
  print(f"{leads[q]:>12}: Median ({thisyear} $): {u.d(medians.iloc[q])}", file=description)
159
163
  print(f"{leads[q]:>12}: Mean ({thisyear} $): {u.d(means.iloc[q])}", file=description)
160
164
  mmin = 1000 * df.iloc[:, q].min()
@@ -209,7 +213,7 @@ class MatplotlibBackend(PlotBackend):
209
213
  g.figure.suptitle(title, y=1.08)
210
214
  return g.figure
211
215
 
212
- def plot_rates(self, name, tau_kn, year_n, year_frac_left, N_k,
216
+ def plot_rates(self, name, tau_kn, year_n, N_k,
213
217
  rate_method, rate_frm=None, rate_to=None, tag=""):
214
218
  """Plot rate values used over the time horizon."""
215
219
  fig, ax = plt.subplots()
@@ -230,19 +234,12 @@ class MatplotlibBackend(PlotBackend):
230
234
  ltype = ["-", "-.", ":", "--"]
231
235
 
232
236
  for k in range(N_k):
233
- # Don't plot partial rates for current year if mid-year.
234
- if year_frac_left == 1:
235
- data = 100 * tau_kn[k]
236
- years = year_n
237
- else:
238
- data = 100 * tau_kn[k, 1:]
239
- years = year_n[1:]
240
-
237
+ data = 100 * tau_kn[k]
241
238
  # Use ddof=1 to match pandas' statistical calculations from numpy.
242
239
  label = (
243
240
  rate_name[k] + " <" + "{:.1f}".format(np.mean(data)) + " +/- {:.1f}".format(np.std(data, ddof=1)) + "%>"
244
241
  )
245
- ax.plot(years, data, label=label, ls=ltype[k % N_k])
242
+ ax.plot(year_n, data, label=label, ls=ltype[k % N_k])
246
243
 
247
244
  ax.xaxis.set_major_locator(tk.MaxNLocator(integer=True))
248
245
  ax.legend(loc="best", reverse=False, fontsize=8, framealpha=0.7)
@@ -330,8 +327,8 @@ class MatplotlibBackend(PlotBackend):
330
327
 
331
328
  return self._line_income_plot(year_n, series, style, title, yformat)[0]
332
329
 
333
- def plot_asset_distribution(self, year_n, inames, b_ijkn, gamma_n, value, name, tag):
334
- """Plot asset distribution over time."""
330
+ def plot_asset_composition(self, year_n, inames, b_ijkn, gamma_n, value, name, tag):
331
+ """Plot asset composition over time."""
335
332
  if value == "nominal":
336
333
  yformat = r"\$k (nominal)"
337
334
  infladjust = 1
@@ -352,7 +349,7 @@ class MatplotlibBackend(PlotBackend):
352
349
  y2stack[namek] = np.zeros((len(inames), len(years_n)))
353
350
  for i in range(len(inames)):
354
351
  y2stack[namek][i][:] = b_ijkn[i][jDic[jkey]][kDic[kkey]][:] / infladjust
355
- title = name + "\nAssets Distribution - " + jkey
352
+ title = name + "\nAsset Composition - " + jkey
356
353
  if tag:
357
354
  title += " - " + tag
358
355
  fig, ax = self._stack_plot(years_n, inames, title, range(len(inames)),
@@ -420,12 +417,12 @@ class MatplotlibBackend(PlotBackend):
420
417
 
421
418
  def plot_taxes(self, year_n, T_n, M_n, gamma_n, value, title, inames):
422
419
  """Plot taxes over time."""
423
- style = {"income taxes": "-", "Medicare": "-."}
420
+ style = {"income tax": "-", "Medicare": "-."}
424
421
  if value == "nominal":
425
- series = {"income taxes": T_n, "Medicare": M_n}
422
+ series = {"income tax": T_n, "Medicare": M_n}
426
423
  yformat = r"\$k (nominal)"
427
424
  else:
428
- series = {"income taxes": T_n / gamma_n[:-1], "Medicare": M_n / gamma_n[:-1]}
425
+ series = {"income tax": T_n / gamma_n[:-1], "Medicare": M_n / gamma_n[:-1]}
429
426
  yformat = r"\$k (" + str(year_n[0]) + r"\$)"
430
427
  fig, ax = self._line_income_plot(year_n, series, style, title, yformat)
431
428
 
@@ -88,6 +88,9 @@ class PlotlyBackend(PlotBackend):
88
88
  margin=dict(b=150)
89
89
  )
90
90
 
91
+ # Format y-axis as number
92
+ fig.update_yaxes(tickformat=",.1f")
93
+
91
94
  return fig
92
95
 
93
96
  def plot_gross_income(self, year_n, G_n, gamma_n, value, title, tax_brackets):
@@ -195,6 +198,11 @@ class PlotlyBackend(PlotBackend):
195
198
  # Format y-axis as k
196
199
  fig.update_yaxes(tickformat=",.0f")
197
200
 
201
+ ymin = np.min(net_data)
202
+ ymax = np.max(net_data)
203
+ if np.abs(ymax - ymin) < 1:
204
+ fig.update_layout(yaxis=dict(range=[np.floor(ymin)-1, np.ceil(ymax)+1]))
205
+
198
206
  return fig
199
207
 
200
208
  def plot_taxes(self, year_n, T_n, M_n, gamma_n, value, title, inames):
@@ -215,7 +223,7 @@ class PlotlyBackend(PlotBackend):
215
223
  fig.add_trace(go.Scatter(
216
224
  x=year_n,
217
225
  y=income_tax_data,
218
- name="income taxes",
226
+ name="income tax",
219
227
  line=dict(width=2)
220
228
  ))
221
229
 
@@ -250,7 +258,7 @@ class PlotlyBackend(PlotBackend):
250
258
 
251
259
  return fig
252
260
 
253
- def plot_rates(self, name, tau_kn, year_n, year_frac_left, N_k, rate_method, rate_frm=None, rate_to=None, tag=""):
261
+ def plot_rates(self, name, tau_kn, year_n, N_k, rate_method, rate_frm=None, rate_to=None, tag=""):
254
262
  """Plot rate values used over the time horizon."""
255
263
  fig = go.Figure()
256
264
 
@@ -273,13 +281,7 @@ class PlotlyBackend(PlotBackend):
273
281
 
274
282
  # Plot each rate
275
283
  for k in range(N_k):
276
- # Don't plot partial rates for current year if mid-year
277
- if year_frac_left == 1:
278
- data = 100 * tau_kn[k]
279
- years = year_n
280
- else:
281
- data = 100 * tau_kn[k, 1:]
282
- years = year_n[1:]
284
+ data = 100 * tau_kn[k]
283
285
 
284
286
  # Calculate mean and std
285
287
  mean_val = np.mean(data)
@@ -288,7 +290,7 @@ class PlotlyBackend(PlotBackend):
288
290
 
289
291
  # Add trace
290
292
  fig.add_trace(go.Scatter(
291
- x=years,
293
+ x=year_n,
292
294
  y=data,
293
295
  name=label,
294
296
  line=dict(
@@ -569,7 +571,10 @@ class PlotlyBackend(PlotBackend):
569
571
  df.drop("partial", axis=1, inplace=True)
570
572
  means = df.mean(axis=0, numeric_only=True)
571
573
  medians = df.median(axis=0, numeric_only=True)
574
+ my[0] = my[1]
572
575
 
576
+ colors = ["orange", "green"]
577
+ nfields = len(means)
573
578
  # Convert to thousands
574
579
  df /= 1000
575
580
 
@@ -591,7 +596,7 @@ class PlotlyBackend(PlotBackend):
591
596
  x=df[col],
592
597
  name=label,
593
598
  opacity=0.7,
594
- marker_color="orange"
599
+ marker_color=colors[i]
595
600
  ))
596
601
 
597
602
  # Update layout
@@ -612,8 +617,9 @@ class PlotlyBackend(PlotBackend):
612
617
  )
613
618
 
614
619
  leads = [f"partial {my[0]}", f" final {my[1]}"]
620
+ leads = leads if nfields == 2 else leads[1:]
615
621
 
616
- elif len(means) == 2:
622
+ elif nfields == 2:
617
623
  # Two separate histograms
618
624
  fig = make_subplots(
619
625
  rows=1, cols=2,
@@ -634,23 +640,12 @@ class PlotlyBackend(PlotBackend):
634
640
  go.Histogram(
635
641
  x=df[col],
636
642
  name=label,
637
- marker_color="orange",
638
- showlegend=False
643
+ marker_color=colors[i],
644
+ showlegend=True
639
645
  ),
640
646
  row=1, col=i+1
641
647
  )
642
648
 
643
- # Add statistics annotation
644
- fig.add_annotation(
645
- x=0.01, y=0.99,
646
- xref=f"x{i+1}",
647
- yref="paper",
648
- text=label,
649
- showarrow=False,
650
- font=dict(size=10),
651
- bgcolor="rgba(0, 0, 0, 0)"
652
- )
653
-
654
649
  # Update layout
655
650
  fig.update_layout(
656
651
  title=title,
@@ -661,7 +656,9 @@ class PlotlyBackend(PlotBackend):
661
656
 
662
657
  # Update y-axis labels
663
658
  fig.update_yaxes(title_text="Count", row=1, col=1)
664
- fig.update_yaxes(title_text="Count", row=1, col=2)
659
+ # fig.update_yaxes(title_text="Count", row=1, col=2)
660
+ fig.update_xaxes(title_text=f"{thisyear} $k", row=1, col=1)
661
+ fig.update_xaxes(title_text=f"{thisyear} $k", row=1, col=2)
665
662
 
666
663
  else:
667
664
  # Single histogram for net spending
@@ -697,7 +694,7 @@ class PlotlyBackend(PlotBackend):
697
694
  leads = [objective]
698
695
 
699
696
  # Add statistics to description
700
- for q in range(len(means)):
697
+ for q in range(nfields):
701
698
  print(f"{leads[q]:>12}: Median ({thisyear} $): {u.d(medians.iloc[q])}", file=description)
702
699
  print(f"{leads[q]:>12}: Mean ({thisyear} $): {u.d(means.iloc[q])}", file=description)
703
700
  mmin = 1000 * df.iloc[:, q].min()
@@ -710,7 +707,7 @@ class PlotlyBackend(PlotBackend):
710
707
 
711
708
  return None, description
712
709
 
713
- def plot_asset_distribution(self, year_n, inames, b_ijkn, gamma_n, value, name, tag):
710
+ def plot_asset_composition(self, year_n, inames, b_ijkn, gamma_n, value, name, tag):
714
711
  """Plot asset distribution over time."""
715
712
  # Set up value formatting
716
713
  if value == "nominal":
@@ -762,7 +759,7 @@ class PlotlyBackend(PlotBackend):
762
759
  ))
763
760
 
764
761
  # Update layout
765
- title = f"{name}<br>Assets Distribution - {jkey}"
762
+ title = f"{name}<br>Asset Composition - {jkey}"
766
763
  if tag:
767
764
  title += f" - {tag}"
768
765
 
owlplanner/version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "2025.05.15"
1
+ __version__ = "2025.05.28"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: owlplanner
3
- Version: 2025.5.15
3
+ Version: 2025.5.28
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
@@ -714,7 +714,7 @@ Description-Content-Type: text/markdown
714
714
  -------------------------------------------------------------------------------------
715
715
 
716
716
  ### TL;DR
717
- Owl is a financial retirement planning tool that uses a linear programming
717
+ Owl is a retirement financial planning tool that uses a linear programming
718
718
  optimization algorithm to provide guidance on retirement decisions
719
719
  such as contributions, withdrawals, Roth conversions, and more.
720
720
  Users can select varying return rates to perform historical back testing,
@@ -1,22 +1,22 @@
1
1
  owlplanner/__init__.py,sha256=hJ2i4m2JpHPAKyQLjYOXpJzeEsgcTcKD-Vhm0AIjjWg,592
2
2
  owlplanner/abcapi.py,sha256=m0vtoEzz9HJV7fOK_d7OnK7ha2Qbf7wLLPCJ9YZzR1k,6851
3
- owlplanner/config.py,sha256=qIWzj3Tbz_jhhFqIkaMzXzgWQBN4Uk2km_VIMZSh910,12559
3
+ owlplanner/config.py,sha256=v6T6A_90rVyl4sfX8KLpI8wkzt9HCjUiGDsPS-4VTec,12588
4
4
  owlplanner/mylogging.py,sha256=tYMw04O-XYSzjTj36fmKJGLcE1VkK6k6oJNeqtKXzuc,2530
5
- owlplanner/plan.py,sha256=_Oq2UomWzteYVlT6zJC_A2zXO3YVlXG9xgUMcQfWBJg,106742
5
+ owlplanner/plan.py,sha256=BnojjOQzzFdcT4dL8EALzc_vzXO2qQJJXjY98nRZIyA,107114
6
6
  owlplanner/progress.py,sha256=8jlCvvtgDI89zXVNMBg1-lnEyhpPvKQS2X5oAIpoOVQ,384
7
7
  owlplanner/rates.py,sha256=MiaibxJY82JGpAhGyF2BJTm5-rmVAUuG8KLApVQhjvU,14816
8
8
  owlplanner/tax2025.py,sha256=wmlZpYeeGNrbyn5g7wOFqhWbggppodtHqc-ex5XRooI,7850
9
9
  owlplanner/timelists.py,sha256=wNYnJqxJ6QqE6jHh5lfFqYngfw5wUFrI15LSsM5ae8s,3949
10
10
  owlplanner/utils.py,sha256=WpJgn79YZfH8UCkcmhd-AZlxlGuz1i1-UDBRXImsY6I,2485
11
- owlplanner/version.py,sha256=qgj6Tm2TUGY2hnWl84UnqBg_PfKL41UcaRHV7ghcCOI,28
11
+ owlplanner/version.py,sha256=5-OFo8vhU5vD37pZxgc1DJNjT8dReNh-zTUao6lJAKE,28
12
12
  owlplanner/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
13
13
  owlplanner/data/rates.csv,sha256=6fxg56BVVORrj9wJlUGFdGXKvOX5r7CSca8uhUbbuIU,3734
14
14
  owlplanner/plotting/__init__.py,sha256=VnF6ui78YrTrg1dA6hBIdI02ahzEaHVR3ZEdDe_i880,103
15
- owlplanner/plotting/base.py,sha256=1bU6iM1pGIE-l9p0GuulX4gRK_7ds96784Wb5oVUUR0,2449
15
+ owlplanner/plotting/base.py,sha256=LP1TByl1tO4m087O6VpbZ_TTMnErHJGLTxXZXC9cuKQ,2431
16
16
  owlplanner/plotting/factory.py,sha256=i1k8m_ISnJw06f_JWlMvOQ7Q0PgV_BoLm05uLwFPvOQ,883
17
- owlplanner/plotting/matplotlib_backend.py,sha256=-M4Am7N0D8Nfv_tKNA1TneFYU_DuW_ZsoUBHRQWD_ok,17887
18
- owlplanner/plotting/plotly_backend.py,sha256=mu6V1pH-jOkKVs2QfQVQ_nlYgrniiHk4nFCx_ygJhiE,33036
19
- owlplanner-2025.5.15.dist-info/METADATA,sha256=kw_BIk15vSKoVPiBCUMl40dVrVPrtoweNhqvIZZP52o,54024
20
- owlplanner-2025.5.15.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
21
- owlplanner-2025.5.15.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
22
- owlplanner-2025.5.15.dist-info/RECORD,,
17
+ owlplanner/plotting/matplotlib_backend.py,sha256=iJm3IBeMA5VUYG_zZxKPIzt4Izv2QWtWvlP656zwJVk,17738
18
+ owlplanner/plotting/plotly_backend.py,sha256=5nqEUJXwLPW1vL9hQijxIUK57sWHvya6ZqIIYof-OjE,32944
19
+ owlplanner-2025.5.28.dist-info/METADATA,sha256=MfSczDcOlFEnxDNOr1HD8NU_fqwvilnF4tTwngbQejE,54024
20
+ owlplanner-2025.5.28.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
21
+ owlplanner-2025.5.28.dist-info/licenses/LICENSE,sha256=IwGE9guuL-ryRPEKi6wFPI_zOhg7zDZbTYuHbSt_SAk,35823
22
+ owlplanner-2025.5.28.dist-info/RECORD,,