owlplanner 2025.12.3__py3-none-any.whl → 2025.12.20__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
owlplanner/plan.py CHANGED
@@ -30,6 +30,8 @@ from . import rates
30
30
  from . import config
31
31
  from . import timelists
32
32
  from . import socialsecurity as socsec
33
+ from . import debts as debts
34
+ from . import fixedassets as fxasst
33
35
  from . import mylogging as log
34
36
  from . import progress
35
37
  from .plotting.factory import PlotFactory
@@ -208,12 +210,12 @@ def _timer(func):
208
210
  return wrapper
209
211
 
210
212
 
211
- class Plan(object):
213
+ class Plan:
212
214
  """
213
215
  This is the main class of the Owl Project.
214
216
  """
215
217
 
216
- def __init__(self, inames, yobs, mobs, expectancy, name, *, verbose=False, logstreams=None):
218
+ def __init__(self, inames, dobs, expectancy, name, *, verbose=False, logstreams=None):
217
219
  """
218
220
  Constructor requires three lists: the first
219
221
  one contains the name(s) of the individual(s),
@@ -249,13 +251,9 @@ class Plan(object):
249
251
  # self.setPlotBackend("matplotlib")
250
252
  self.setPlotBackend("plotly")
251
253
 
252
- self.N_i = len(yobs)
254
+ self.N_i = len(dobs)
253
255
  if not (0 <= self.N_i <= 2):
254
256
  raise ValueError(f"Cannot support {self.N_i} individuals.")
255
- if len(mobs) != len(yobs):
256
- raise ValueError("Months and years arrays should have same length.")
257
- if min(mobs) < 1 or max(mobs) > 12:
258
- raise ValueError("Months must be between 1 and 12.")
259
257
  if self.N_i != len(expectancy):
260
258
  raise ValueError(f"Expectancy must have {self.N_i} entries.")
261
259
  if self.N_i != len(inames):
@@ -268,8 +266,8 @@ class Plan(object):
268
266
  # Default year OBBBA speculated to be expired and replaced by pre-TCJA rates.
269
267
  self.yOBBBA = 2032
270
268
  self.inames = inames
271
- self.yobs = np.array(yobs, dtype=np.int32)
272
- self.mobs = np.array(mobs, dtype=np.int32)
269
+ self.yobs, self.mobs, self.tobs = u.parseDobs(dobs)
270
+ self.dobs = dobs
273
271
  self.expectancy = np.array(expectancy, dtype=np.int32)
274
272
 
275
273
  # Reference time is starting date in the current year and all passings are assumed at the end.
@@ -316,6 +314,19 @@ class Plan(object):
316
314
  self.myRothX_in = np.zeros((self.N_i, self.N_n + 5))
317
315
  self.kappa_ijn = np.zeros((self.N_i, self.N_j, self.N_n + 5))
318
316
 
317
+ # Debt payments array (length N_n)
318
+ self.debt_payments_n = np.zeros(self.N_n)
319
+
320
+ # Fixed assets arrays (length N_n)
321
+ self.fixed_assets_tax_free_n = np.zeros(self.N_n)
322
+ self.fixed_assets_ordinary_income_n = np.zeros(self.N_n)
323
+ self.fixed_assets_capital_gains_n = np.zeros(self.N_n)
324
+ # Fixed assets bequest value (assets with yod past plan end)
325
+ self.fixed_assets_bequest_value = 0.0
326
+
327
+ # Remaining debt balance at end of plan
328
+ self.remaining_debt_balance = 0.0
329
+
319
330
  # Previous 2 years of MAGI needed for Medicare.
320
331
  self.prevMAGI = np.zeros((2))
321
332
  self.MAGI_n = np.zeros(self.N_n)
@@ -341,6 +352,7 @@ class Plan(object):
341
352
  self._adjustedParameters = False
342
353
  self.timeListsFileName = "None"
343
354
  self.timeLists = {}
355
+ self.houseLists = {}
344
356
  self.zeroContributions()
345
357
  self.caseStatus = "unsolved"
346
358
  self.rateMethod = None
@@ -593,18 +605,29 @@ class Plan(object):
593
605
  thisyear = date.today().year
594
606
  self.zeta_in = np.zeros((self.N_i, self.N_n))
595
607
  for i in range(self.N_i):
608
+ # Check if age is in bound.
609
+ bornOnFirstDays = (self.tobs[i] <= 2)
610
+ bornOnFirst = (self.tobs[i] == 1)
611
+
612
+ eligible = 62 if bornOnFirstDays else 62 + 1/12
613
+ if ages[i] < eligible:
614
+ self.mylog.vprint(f"Resetting starting age of {self.inames[i]} to {eligible}.")
615
+ ages[i] = eligible
616
+
596
617
  # Check if claim age added to birth month falls next year.
597
- realage = ages[i] + (self.mobs[i] - 1)/12
598
- iage = int(realage)
599
- realns = iage - thisyear + self.yobs[i]
618
+ # janage is age with reference to Jan 1 of yob.
619
+ janage = ages[i] + (self.mobs[i] - 1)/12
620
+ iage = int(janage)
621
+ realns = self.yobs[i] + iage - thisyear
600
622
  ns = max(0, realns)
601
623
  nd = self.horizons[i]
602
624
  self.zeta_in[i, ns:nd] = pias[i]
603
625
  # Reduce starting year due to month offset. If realns < 0, this has happened already.
604
626
  if realns >= 0:
605
- self.zeta_in[i, ns] *= 1 - (realage % 1.)
627
+ self.zeta_in[i, ns] *= 1 - (janage % 1.)
628
+
606
629
  # Increase/decrease PIA due to claiming age.
607
- self.zeta_in[i, :] *= socsec.getSelfFactor(fras[i], ages[i])
630
+ self.zeta_in[i, :] *= socsec.getSelfFactor(fras[i], ages[i], bornOnFirst)
608
631
 
609
632
  # Add spousal benefits if applicable.
610
633
  if self.N_i == 2 and spousalBenefits[i] > 0:
@@ -612,7 +635,7 @@ class Plan(object):
612
635
  claimYear = max(self.yobs + (self.mobs - 1)/12 + ages)
613
636
  claimAge = claimYear - self.yobs[i] - (self.mobs[i] - 1)/12
614
637
  ns2 = max(0, int(claimYear) - thisyear)
615
- spousalFactor = socsec.getSpousalFactor(fras[i], claimAge)
638
+ spousalFactor = socsec.getSpousalFactor(fras[i], claimAge, bornOnFirst)
616
639
  self.zeta_in[i, ns2:nd] += spousalBenefits[i] * spousalFactor
617
640
  # Reduce first year of benefit by month offset.
618
641
  self.zeta_in[i, ns2] -= spousalBenefits[i] * spousalFactor * (claimYear % 1.)
@@ -906,9 +929,9 @@ class Plan(object):
906
929
  Missing rows (years) are populated with zero values.
907
930
  """
908
931
  try:
909
- filename, self.timeLists = timelists.read(filename, self.inames, self.horizons, self.mylog)
932
+ filename, self.timeLists, self.houseLists = timelists.read(filename, self.inames, self.horizons, self.mylog)
910
933
  except Exception as e:
911
- raise Exception(f"Unsuccessful read of Wages and Contributions: {e}") from e
934
+ raise Exception(f"Unsuccessful read of Household Financial Profile: {e}") from e
912
935
 
913
936
  self.timeListsFileName = filename
914
937
  self.setContributions()
@@ -942,9 +965,72 @@ class Plan(object):
942
965
 
943
966
  return self.timeLists
944
967
 
968
+ def processDebtsAndFixedAssets(self):
969
+ """
970
+ Process debts and fixed assets from houseLists and populate arrays.
971
+ Should be called after setContributions() and before solve().
972
+ """
973
+ thisyear = date.today().year
974
+
975
+ # Process debts
976
+ if "Debts" in self.houseLists and not self.houseLists["Debts"].empty:
977
+ self.debt_payments_n = debts.get_debt_payments_array(
978
+ self.houseLists["Debts"], self.N_n, thisyear
979
+ )
980
+ self.remaining_debt_balance = debts.get_remaining_debt_balance(
981
+ self.houseLists["Debts"], self.N_n, thisyear
982
+ )
983
+ else:
984
+ self.debt_payments_n = np.zeros(self.N_n)
985
+ self.remaining_debt_balance = 0.0
986
+
987
+ # Process fixed assets
988
+ if "Fixed Assets" in self.houseLists and not self.houseLists["Fixed Assets"].empty:
989
+ filing_status = "married" if self.N_i == 2 else "single"
990
+ (self.fixed_assets_tax_free_n,
991
+ self.fixed_assets_ordinary_income_n,
992
+ self.fixed_assets_capital_gains_n) = fxasst.get_fixed_assets_arrays(
993
+ self.houseLists["Fixed Assets"], self.N_n, thisyear, filing_status
994
+ )
995
+ # Calculate bequest value for assets with yod past plan end
996
+ self.fixed_assets_bequest_value = fxasst.get_fixed_assets_bequest_value(
997
+ self.houseLists["Fixed Assets"], self.N_n, thisyear
998
+ )
999
+ else:
1000
+ self.fixed_assets_tax_free_n = np.zeros(self.N_n)
1001
+ self.fixed_assets_ordinary_income_n = np.zeros(self.N_n)
1002
+ self.fixed_assets_capital_gains_n = np.zeros(self.N_n)
1003
+ self.fixed_assets_bequest_value = 0.0
1004
+
1005
+ def getFixedAssetsBequestValueInTodaysDollars(self):
1006
+ """
1007
+ Return the fixed assets bequest value in today's dollars.
1008
+ This requires rates to be set to calculate gamma_n (inflation factor).
1009
+
1010
+ Returns:
1011
+ --------
1012
+ float
1013
+ Fixed assets bequest value in today's dollars.
1014
+ Returns 0.0 if rates not set, gamma_n not calculated, or no fixed assets.
1015
+ """
1016
+ if self.fixed_assets_bequest_value == 0.0:
1017
+ return 0.0
1018
+
1019
+ # Check if we can calculate gamma_n
1020
+ if self.rateMethod is None or not hasattr(self, 'tau_kn'):
1021
+ # Rates not set yet - return 0
1022
+ return 0.0
1023
+
1024
+ # Calculate gamma_n if not already calculated
1025
+ if not hasattr(self, 'gamma_n') or self.gamma_n is None:
1026
+ self.gamma_n = _genGamma_n(self.tau_kn)
1027
+
1028
+ # Convert: today's dollars = nominal dollars / inflation_factor
1029
+ return self.fixed_assets_bequest_value / self.gamma_n[-1]
1030
+
945
1031
  def saveContributions(self):
946
1032
  """
947
- Return workbook on wages and contributions.
1033
+ Return workbook on wages and contributions, including Debts and Fixed Assets.
948
1034
  """
949
1035
  if self.timeLists is None:
950
1036
  return None
@@ -966,6 +1052,36 @@ class Plan(object):
966
1052
  ws = wb.create_sheet(self.inames[1])
967
1053
  fillsheet(ws, 1)
968
1054
 
1055
+ # Add Debts sheet if available
1056
+ if "Debts" in self.houseLists and not self.houseLists["Debts"].empty:
1057
+ ws = wb.create_sheet("Debts")
1058
+ df = self.houseLists["Debts"]
1059
+ for row in dataframe_to_rows(df, index=False, header=True):
1060
+ ws.append(row)
1061
+ _formatDebtsSheet(ws)
1062
+ else:
1063
+ # Create empty Debts sheet with proper columns
1064
+ ws = wb.create_sheet("Debts")
1065
+ df = pd.DataFrame(columns=["name", "type", "year", "term", "amount", "rate"])
1066
+ for row in dataframe_to_rows(df, index=False, header=True):
1067
+ ws.append(row)
1068
+ _formatDebtsSheet(ws)
1069
+
1070
+ # Add Fixed Assets sheet if available
1071
+ if "Fixed Assets" in self.houseLists and not self.houseLists["Fixed Assets"].empty:
1072
+ ws = wb.create_sheet("Fixed Assets")
1073
+ df = self.houseLists["Fixed Assets"]
1074
+ for row in dataframe_to_rows(df, index=False, header=True):
1075
+ ws.append(row)
1076
+ _formatFixedAssetsSheet(ws)
1077
+ else:
1078
+ # Create empty Fixed Assets sheet with proper columns
1079
+ ws = wb.create_sheet("Fixed Assets")
1080
+ df = pd.DataFrame(columns=["name", "type", "basis", "value", "rate", "yod", "commission"])
1081
+ for row in dataframe_to_rows(df, index=False, header=True):
1082
+ ws.append(row)
1083
+ _formatFixedAssetsSheet(ws)
1084
+
969
1085
  return wb
970
1086
 
971
1087
  def zeroContributions(self):
@@ -1160,25 +1276,26 @@ class Plan(object):
1160
1276
  h = self.horizons[i]
1161
1277
  for n in range(h):
1162
1278
  rhs = 0
1163
- # To add compounded gains to original amount.
1279
+ # To add compounded gains to cumulative amounts. Always keep cgains >= 1.
1164
1280
  cgains = 1
1165
1281
  row = self.A.newRow()
1166
1282
  row.addElem(_q3(self.C["b"], i, 2, n, self.N_i, self.N_j, self.N_n + 1), 1)
1167
1283
  row.addElem(_q3(self.C["w"], i, 2, n, self.N_i, self.N_j, self.N_n), -1)
1168
1284
  for dn in range(1, 6):
1169
1285
  nn = n - dn
1170
- if nn >= 0: # Past of future is now or in the future: use variables and parameters.
1286
+ if nn >= 0: # Past of future is now or in the future: use variables or parameters.
1171
1287
  Tau1 = 1 + np.sum(self.alpha_ijkn[i, 2, :, nn] * self.tau_kn[:, nn], axis=0)
1172
- cgains *= Tau1
1288
+ # Ignore market downs.
1289
+ cgains *= max(1, Tau1)
1173
1290
  row.addElem(_q2(self.C["x"], i, nn, self.N_i, self.N_n), -cgains)
1174
- # If a contribution - it can be withdrawn but not the gains.
1291
+ # If a contribution, it has only penalty on gains, not on deposited amount.
1175
1292
  rhs += (cgains - 1) * self.kappa_ijn[i, 2, nn]
1176
1293
  else: # Past of future is in the past:
1177
- # Parameters are stored at the end of contributions and conversions arrays.
1178
1294
  cgains *= oldTau1
1179
- # If a contribution, it has no penalty, but assume a conversion.
1180
- # rhs += (cgains - 1) * self.kappa_ijn[i, 2, nn] + cgains * self.myRothX_in[i, nn]
1181
- rhs += cgains * self.kappa_ijn[i, 2, nn] + cgains * self.myRothX_in[i, nn]
1295
+ # Past years are stored at the end of contributions and conversions arrays.
1296
+ # Use negative index to access tail of array.
1297
+ # Past years are stored at the end of arrays, accessed via negative indexing
1298
+ rhs += (cgains - 1) * self.kappa_ijn[i, 2, nn] + cgains * self.myRothX_in[i, nn]
1182
1299
 
1183
1300
  self.A.addRow(row, rhs, np.inf)
1184
1301
 
@@ -1248,12 +1365,19 @@ class Plan(object):
1248
1365
  else:
1249
1366
  bequest = 1
1250
1367
 
1368
+ # Bequest constraint now refers only to savings accounts
1369
+ # User specifies desired bequest from accounts (fixed assets are separate)
1370
+ # Total bequest = accounts - debts + fixed_assets
1371
+ # So: accounts >= desired_bequest_from_accounts + debts
1372
+ # (fixed_assets are added separately in the total bequest calculation)
1373
+ total_bequest_value = bequest + self.remaining_debt_balance
1374
+
1251
1375
  row = self.A.newRow()
1252
1376
  for i in range(self.N_i):
1253
1377
  row.addElem(_q3(self.C["b"], i, 0, self.N_n, self.N_i, self.N_j, self.N_n + 1), 1)
1254
1378
  row.addElem(_q3(self.C["b"], i, 1, self.N_n, self.N_i, self.N_j, self.N_n + 1), 1 - self.nu)
1255
1379
  row.addElem(_q3(self.C["b"], i, 2, self.N_n, self.N_i, self.N_j, self.N_n + 1), 1)
1256
- self.A.addRow(row, bequest, bequest)
1380
+ self.A.addRow(row, total_bequest_value, total_bequest_value)
1257
1381
  elif objective == "maxBequest":
1258
1382
  spending = options["netSpending"]
1259
1383
  if not isinstance(spending, (int, float)):
@@ -1335,6 +1459,10 @@ class Plan(object):
1335
1459
  tau_0prev[tau_0prev < 0] = 0
1336
1460
  for n in range(self.N_n):
1337
1461
  rhs = -self.M_n[n] - self.J_n[n]
1462
+ # Add fixed assets tax-free money (positive cash flow)
1463
+ rhs += self.fixed_assets_tax_free_n[n]
1464
+ # Subtract debt payments (negative cash flow)
1465
+ rhs -= self.debt_payments_n[n]
1338
1466
  row = self.A.newRow({_q1(self.C["g"], n, self.N_n): 1})
1339
1467
  row.addElem(_q1(self.C["s"], n, self.N_n), 1)
1340
1468
  row.addElem(_q1(self.C["m"], n, self.N_n), 1)
@@ -1372,7 +1500,8 @@ class Plan(object):
1372
1500
 
1373
1501
  def _add_taxable_income(self):
1374
1502
  for n in range(self.N_n):
1375
- rhs = 0
1503
+ # Add fixed assets ordinary income
1504
+ rhs = self.fixed_assets_ordinary_income_n[n]
1376
1505
  row = self.A.newRow()
1377
1506
  row.addElem(_q1(self.C["e"], n, self.N_n), 1)
1378
1507
  for i in range(self.N_i):
@@ -1466,16 +1595,19 @@ class Plan(object):
1466
1595
  row1.addElem(_q2(self.C["x"], i, n2, self.N_i, self.N_n), -1)
1467
1596
  row2.addElem(_q2(self.C["x"], i, n2, self.N_i, self.N_n), +1)
1468
1597
 
1598
+ # Dividends and interest gains for year n2.
1469
1599
  afac = (self.mu*self.alpha_ijkn[i, 0, 0, n2]
1470
1600
  + np.sum(self.alpha_ijkn[i, 0, 1:, n2]*self.tau_kn[1:, n2]))
1471
- afac = 0
1601
+
1472
1602
  row1.addElem(_q3(self.C["b"], i, 0, n2, self.N_i, self.N_j, self.N_n + 1), -afac)
1473
1603
  row2.addElem(_q3(self.C["b"], i, 0, n2, self.N_i, self.N_j, self.N_n + 1), +afac)
1474
1604
 
1475
1605
  row1.addElem(_q2(self.C["d"], i, n2, self.N_i, self.N_n), -afac)
1476
1606
  row2.addElem(_q2(self.C["d"], i, n2, self.N_i, self.N_n), +afac)
1477
1607
 
1608
+ # Capital gains on stocks sold from taxable account accrued in year n2 - 1.
1478
1609
  bfac = self.alpha_ijkn[i, 0, 0, n2] * max(0, self.tau_kn[0, max(0, n2-1)])
1610
+
1479
1611
  row1.addElem(_q3(self.C["w"], i, 0, n2, self.N_i, self.N_j, self.N_n), +afac - bfac)
1480
1612
  row2.addElem(_q3(self.C["w"], i, 0, n2, self.N_i, self.N_j, self.N_n), -afac + bfac)
1481
1613
 
@@ -1732,6 +1864,9 @@ class Plan(object):
1732
1864
  self._adjustParameters(self.gamma_n, self.MAGI_n)
1733
1865
  self._buildOffsetMap(options)
1734
1866
 
1867
+ # Process debts and fixed assets
1868
+ self.processDebtsAndFixedAssets()
1869
+
1735
1870
  solver = myoptions.get("solver", self.defaultSolver)
1736
1871
  if solver not in knownSolvers:
1737
1872
  raise ValueError(f"Unknown solver {solver}.")
@@ -1880,7 +2015,7 @@ class Plan(object):
1880
2015
  elif vkeys[i] == "fx":
1881
2016
  x += [pulp.LpVariable(f"x_{i}", cat="Continuous", lowBound=Lb[i], upBound=Ub[i])]
1882
2017
  else:
1883
- raise RuntimeError(f"Internal error: Variable with wierd bound f{vkeys[i]}.")
2018
+ raise RuntimeError(f"Internal error: Variable with weird bound {vkeys[i]}.")
1884
2019
 
1885
2020
  x.extend([pulp.LpVariable(f"z_{i}", cat="Binary") for i in range(self.nbins)])
1886
2021
 
@@ -1978,26 +2113,6 @@ class Plan(object):
1978
2113
 
1979
2114
  return solution, xx, solverSuccess, solverMsg
1980
2115
 
1981
- def _computeNIIT(self, MAGI_n, I_n, Q_n):
1982
- """
1983
- Compute ACA tax on Dividends (Q) and Interests (I).
1984
- Pass arguments to better understand dependencies.
1985
- For accounting for rent and/or trust income, one can easily add a column
1986
- to the Wages and Contributions file and add yearly amount to Q_n + I_n below.
1987
- """
1988
- J_n = np.zeros(self.N_n)
1989
- status = len(self.yobs) - 1
1990
-
1991
- for n in range(self.N_n):
1992
- if status and n == self.n_d:
1993
- status -= 1
1994
-
1995
- Gmax = tx.niitThreshold[status]
1996
- if MAGI_n[n] > Gmax:
1997
- J_n[n] = tx.niitRate * min(MAGI_n[n] - Gmax, I_n[n] + Q_n[n])
1998
-
1999
- return J_n
2000
-
2001
2116
  def _computeNLstuff(self, x, includeMedicare):
2002
2117
  """
2003
2118
  Compute MAGI, Medicare costs, long-term capital gain tax rate, and
@@ -2012,7 +2127,7 @@ class Plan(object):
2012
2127
 
2013
2128
  self._aggregateResults(x, short=True)
2014
2129
 
2015
- self.J_n = self._computeNIIT(self.MAGI_n, self.I_n, self.Q_n)
2130
+ self.J_n = tx.computeNIIT(self.N_i, self.MAGI_n, self.I_n, self.Q_n, self.n_d, self.N_n)
2016
2131
  self.psi_n = tx.capitalGainTaxRate(self.N_i, self.MAGI_n, self.gamma_n[:-1], self.n_d, self.N_n)
2017
2132
  # Compute Medicare through self-consistent loop.
2018
2133
  if includeMedicare:
@@ -2093,6 +2208,8 @@ class Plan(object):
2093
2208
  * self.alpha_ijkn[:, 0, 0, :Nn],
2094
2209
  axis=0,
2095
2210
  )
2211
+ # Add fixed assets capital gains
2212
+ self.Q_n += self.fixed_assets_capital_gains_n
2096
2213
  self.U_n = self.psi_n * self.Q_n
2097
2214
 
2098
2215
  self.MAGI_n = self.G_n + self.e_n + self.Q_n
@@ -2101,7 +2218,7 @@ class Plan(object):
2101
2218
  * np.sum(self.alpha_ijkn[:, 0, 1:, :Nn] * self.tau_kn[1:, :], axis=1))
2102
2219
  self.I_n = np.sum(I_in, axis=0)
2103
2220
 
2104
- # Stop after building minimu required for self-consistent loop.
2221
+ # Stop after building minimum required for self-consistent loop.
2105
2222
  if short:
2106
2223
  return
2107
2224
 
@@ -2164,6 +2281,34 @@ class Plan(object):
2164
2281
  sources["RothX"] = self.x_in
2165
2282
  sources["tax-free wdrwl"] = self.w_ijn[:, 2, :]
2166
2283
  sources["BTI"] = self.Lambda_in
2284
+ # Debts and fixed assets (debts are negative as expenses)
2285
+ # Reshape 1D arrays to match shape of other sources (N_i x N_n)
2286
+ if self.N_i == 1:
2287
+ sources["debt payments"] = -self.debt_payments_n.reshape(1, -1)
2288
+ sources["fixed assets tax-free"] = self.fixed_assets_tax_free_n.reshape(1, -1)
2289
+ sources["fixed assets ordinary"] = self.fixed_assets_ordinary_income_n.reshape(1, -1)
2290
+ sources["fixed assets capital gains"] = self.fixed_assets_capital_gains_n.reshape(1, -1)
2291
+ else:
2292
+ # For married couples, split using eta between individuals.
2293
+ debt_array = np.zeros((self.N_i, self.N_n))
2294
+ debt_array[0, :] = -self.debt_payments_n * (1 - self.eta)
2295
+ debt_array[1, :] = -self.debt_payments_n * self.eta
2296
+ sources["debt payments"] = debt_array
2297
+
2298
+ fa_tax_free = np.zeros((self.N_i, self.N_n))
2299
+ fa_tax_free[0, :] = self.fixed_assets_tax_free_n * (1 - self.eta)
2300
+ fa_tax_free[1, :] = self.fixed_assets_tax_free_n * self.eta
2301
+ sources["fixed assets tax-free"] = fa_tax_free
2302
+
2303
+ fa_ordinary = np.zeros((self.N_i, self.N_n))
2304
+ fa_ordinary[0, :] = self.fixed_assets_ordinary_income_n * (1 - self.eta)
2305
+ fa_ordinary[1, :] = self.fixed_assets_ordinary_income_n * self.eta
2306
+ sources["fixed assets ordinary"] = fa_ordinary
2307
+
2308
+ fa_capital = np.zeros((self.N_i, self.N_n))
2309
+ fa_capital[0, :] = self.fixed_assets_capital_gains_n * (1 - self.eta)
2310
+ fa_capital[1, :] = self.fixed_assets_capital_gains_n * self.eta
2311
+ sources["fixed assets capital gains"] = fa_capital
2167
2312
 
2168
2313
  savings = {}
2169
2314
  savings["taxable"] = self.b_ijn[:, 0, :]
@@ -2175,7 +2320,9 @@ class Plan(object):
2175
2320
 
2176
2321
  estate_j = np.sum(self.b_ijn[:, :, self.N_n], axis=0)
2177
2322
  estate_j[1] *= 1 - self.nu
2178
- self.bequest = np.sum(estate_j) / self.gamma_n[-1]
2323
+ # Subtract remaining debt balance from estate
2324
+ total_estate = np.sum(estate_j) - self.remaining_debt_balance
2325
+ self.bequest = max(0.0, total_estate) / self.gamma_n[-1]
2179
2326
 
2180
2327
  self.basis = self.g_n[0] / self.xi_n[0]
2181
2328
 
@@ -2262,7 +2409,10 @@ class Plan(object):
2262
2409
  for t in range(self.N_t):
2263
2410
  taxPaid = np.sum(self.T_tn[t], axis=0)
2264
2411
  taxPaidNow = np.sum(self.T_tn[t] / self.gamma_n[:-1], axis=0)
2265
- tname = tx.taxBracketNames[t]
2412
+ if t >= len(tx.taxBracketNames):
2413
+ tname = f"Bracket {t}"
2414
+ else:
2415
+ tname = tx.taxBracketNames[t]
2266
2416
  dic[f"» Subtotal in tax bracket {tname}"] = f"{u.d(taxPaidNow)}"
2267
2417
  dic[f"» [Subtotal in tax bracket {tname}]"] = f"{u.d(taxPaid)}"
2268
2418
 
@@ -2286,6 +2436,12 @@ class Plan(object):
2286
2436
  dic[" Total Medicare premiums paid"] = f"{u.d(taxPaidNow)}"
2287
2437
  dic["[Total Medicare premiums paid]"] = f"{u.d(taxPaid)}"
2288
2438
 
2439
+ totDebtPayments = np.sum(self.debt_payments_n, axis=0)
2440
+ if totDebtPayments > 0:
2441
+ totDebtPaymentsNow = np.sum(self.debt_payments_n / self.gamma_n[:-1], axis=0)
2442
+ dic[" Total debt payments"] = f"{u.d(totDebtPaymentsNow)}"
2443
+ dic["[Total debt payments]"] = f"{u.d(totDebtPayments)}"
2444
+
2289
2445
  if self.N_i == 2 and self.n_d < self.N_n:
2290
2446
  p_j = self.partialEstate_j * (1 - self.phi_j)
2291
2447
  p_j[1] *= 1 - self.nu
@@ -2320,9 +2476,16 @@ class Plan(object):
2320
2476
  estate[1] *= 1 - self.nu
2321
2477
  endyear = self.year_n[-1]
2322
2478
  lyNow = 1./self.gamma_n[-1]
2323
- totEstate = np.sum(estate)
2479
+ debts = self.remaining_debt_balance
2480
+ # Add fixed assets bequest value (assets with yod past plan end)
2481
+ totEstate = np.sum(estate) - debts + self.fixed_assets_bequest_value
2324
2482
  dic["Year of final bequest"] = (f"{endyear}")
2325
2483
  dic[" Total value of final bequest"] = (f"{u.d(lyNow*totEstate)}")
2484
+ if debts > 0:
2485
+ dic[" After paying remaining debts of"] = (f"{u.d(lyNow*debts)}")
2486
+ if self.fixed_assets_bequest_value > 0:
2487
+ dic[" Fixed assets liquidated at end of plan"] = (f"{u.d(lyNow*self.fixed_assets_bequest_value)}")
2488
+ dic["[Fixed assets liquidated at end of plan]"] = (f"{u.d(self.fixed_assets_bequest_value)}")
2326
2489
  dic["[Total value of final bequest]"] = (f"{u.d(totEstate)}")
2327
2490
  dic["» Post-tax final bequest account value - taxable"] = (f"{u.d(lyNow*estate[0])}")
2328
2491
  dic["» [Post-tax final bequest account value - taxable]"] = (f"{u.d(estate[0])}")
@@ -2330,6 +2493,8 @@ class Plan(object):
2330
2493
  dic["» [Post-tax final bequest account value - tax-def]"] = (f"{u.d(estate[1])}")
2331
2494
  dic["» Post-tax final bequest account value - tax-free"] = (f"{u.d(lyNow*estate[2])}")
2332
2495
  dic["» [Post-tax final bequest account value - tax-free]"] = (f"{u.d(estate[2])}")
2496
+ if debts > 0:
2497
+ dic["» [Remaining debt balance]"] = (f"{u.d(debts)}")
2333
2498
 
2334
2499
  dic["Plan starting date"] = str(self.startDate)
2335
2500
  dic["Cumulative inflation factor at end of final year"] = (f"{self.gamma_n[-1]:.2f}")
@@ -2712,6 +2877,20 @@ class Plan(object):
2712
2877
  ws.append(lastRow)
2713
2878
  _formatSpreadsheet(ws, "currency")
2714
2879
 
2880
+ # Federal income tax brackets.
2881
+ TxDic = {}
2882
+ for t in range(self.N_t):
2883
+ TxDic[tx.taxBracketNames[t]] = self.T_tn[t, :]
2884
+
2885
+ TxDic["total"] = self.T_n
2886
+ TxDic["NIIT"] = self.J_n
2887
+ TxDic["LTCG"] = self.U_n
2888
+ TxDic["10% penalty"] = self.P_n
2889
+
2890
+ sname = "Federal Income Tax"
2891
+ ws = wb.create_sheet(sname)
2892
+ fillsheet(ws, TxDic, "currency")
2893
+
2715
2894
  # Allocations.
2716
2895
  jDic = {"taxable": 0, "tax-deferred": 1, "tax-free": 2}
2717
2896
  kDic = {"stocks": 0, "C bonds": 1, "T notes": 2, "common": 3}
@@ -2893,3 +3072,93 @@ def _formatSpreadsheet(ws, ftype):
2893
3072
  cell.number_format = fstring
2894
3073
 
2895
3074
  return None
3075
+
3076
+
3077
+ def _formatDebtsSheet(ws):
3078
+ """
3079
+ Format Debts sheet with appropriate column formatting.
3080
+ """
3081
+ from openpyxl.utils import get_column_letter
3082
+
3083
+ # Format header row
3084
+ for cell in ws[1]:
3085
+ cell.style = "Pandas"
3086
+
3087
+ # Get column mapping from header
3088
+ header_row = ws[1]
3089
+ col_map = {}
3090
+ for idx, cell in enumerate(header_row, start=1):
3091
+ col_letter = get_column_letter(idx)
3092
+ col_name = str(cell.value).lower() if cell.value else ""
3093
+ col_map[col_letter] = col_name
3094
+ # Set column width
3095
+ width = max(len(str(cell.value)) + 4, 10)
3096
+ ws.column_dimensions[col_letter].width = width
3097
+
3098
+ # Apply formatting based on column name
3099
+ for col_letter, col_name in col_map.items():
3100
+ if col_name in ["year", "term"]:
3101
+ # Integer format
3102
+ fstring = "#,##0"
3103
+ elif col_name in ["rate"]:
3104
+ # Number format (2 decimal places for percentages stored as numbers)
3105
+ fstring = "#,##0.00"
3106
+ elif col_name in ["amount"]:
3107
+ # Currency format
3108
+ fstring = "$#,##0_);[Red]($#,##0)"
3109
+ else:
3110
+ # Text columns (name, type) - no number formatting
3111
+ continue
3112
+
3113
+ # Apply formatting to all data rows (skip header row 1)
3114
+ for row in ws.iter_rows(min_row=2):
3115
+ for cell in row:
3116
+ if cell.column_letter == col_letter:
3117
+ cell.number_format = fstring
3118
+
3119
+ return None
3120
+
3121
+
3122
+ def _formatFixedAssetsSheet(ws):
3123
+ """
3124
+ Format Fixed Assets sheet with appropriate column formatting.
3125
+ """
3126
+ from openpyxl.utils import get_column_letter
3127
+
3128
+ # Format header row
3129
+ for cell in ws[1]:
3130
+ cell.style = "Pandas"
3131
+
3132
+ # Get column mapping from header
3133
+ header_row = ws[1]
3134
+ col_map = {}
3135
+ for idx, cell in enumerate(header_row, start=1):
3136
+ col_letter = get_column_letter(idx)
3137
+ col_name = str(cell.value).lower() if cell.value else ""
3138
+ col_map[col_letter] = col_name
3139
+ # Set column width
3140
+ width = max(len(str(cell.value)) + 4, 10)
3141
+ ws.column_dimensions[col_letter].width = width
3142
+
3143
+ # Apply formatting based on column name
3144
+ for col_letter, col_name in col_map.items():
3145
+ if col_name in ["yod"]:
3146
+ # Integer format
3147
+ fstring = "#,##0"
3148
+ elif col_name in ["rate", "commission"]:
3149
+ # Number format (1 decimal place for percentages stored as numbers)
3150
+ fstring = "#,##0.00"
3151
+ elif col_name in ["basis", "value"]:
3152
+ # Currency format
3153
+ fstring = "$#,##0_);[Red]($#,##0)"
3154
+ else:
3155
+ # Text columns (name, type) - no number formatting
3156
+ continue
3157
+
3158
+ # Apply formatting to all data rows (skip header row 1)
3159
+ for row in ws.iter_rows(min_row=2):
3160
+ for cell in row:
3161
+ if cell.column_letter == col_letter:
3162
+ cell.number_format = fstring
3163
+
3164
+ return None
@@ -829,7 +829,7 @@ class PlotlyBackend(PlotBackend):
829
829
  stack_data.append(data)
830
830
 
831
831
  # Add stacked area traces
832
- for data, name in zip(stack_data, stack_names):
832
+ for data, name in zip(stack_data, stack_names, strict=True):
833
833
  fig.add_trace(go.Scatter(
834
834
  x=year_n,
835
835
  y=data,