owlplanner 2025.12.5__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):
@@ -1178,8 +1294,8 @@ class Plan(object):
1178
1294
  cgains *= oldTau1
1179
1295
  # Past years are stored at the end of contributions and conversions arrays.
1180
1296
  # Use negative index to access tail of array.
1297
+ # Past years are stored at the end of arrays, accessed via negative indexing
1181
1298
  rhs += (cgains - 1) * self.kappa_ijn[i, 2, nn] + cgains * self.myRothX_in[i, nn]
1182
- # rhs += cgains * self.kappa_ijn[i, 2, nn] + cgains * self.myRothX_in[i, nn]
1183
1299
 
1184
1300
  self.A.addRow(row, rhs, np.inf)
1185
1301
 
@@ -1249,12 +1365,19 @@ class Plan(object):
1249
1365
  else:
1250
1366
  bequest = 1
1251
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
+
1252
1375
  row = self.A.newRow()
1253
1376
  for i in range(self.N_i):
1254
1377
  row.addElem(_q3(self.C["b"], i, 0, self.N_n, self.N_i, self.N_j, self.N_n + 1), 1)
1255
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)
1256
1379
  row.addElem(_q3(self.C["b"], i, 2, self.N_n, self.N_i, self.N_j, self.N_n + 1), 1)
1257
- self.A.addRow(row, bequest, bequest)
1380
+ self.A.addRow(row, total_bequest_value, total_bequest_value)
1258
1381
  elif objective == "maxBequest":
1259
1382
  spending = options["netSpending"]
1260
1383
  if not isinstance(spending, (int, float)):
@@ -1336,6 +1459,10 @@ class Plan(object):
1336
1459
  tau_0prev[tau_0prev < 0] = 0
1337
1460
  for n in range(self.N_n):
1338
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]
1339
1466
  row = self.A.newRow({_q1(self.C["g"], n, self.N_n): 1})
1340
1467
  row.addElem(_q1(self.C["s"], n, self.N_n), 1)
1341
1468
  row.addElem(_q1(self.C["m"], n, self.N_n), 1)
@@ -1373,7 +1500,8 @@ class Plan(object):
1373
1500
 
1374
1501
  def _add_taxable_income(self):
1375
1502
  for n in range(self.N_n):
1376
- rhs = 0
1503
+ # Add fixed assets ordinary income
1504
+ rhs = self.fixed_assets_ordinary_income_n[n]
1377
1505
  row = self.A.newRow()
1378
1506
  row.addElem(_q1(self.C["e"], n, self.N_n), 1)
1379
1507
  for i in range(self.N_i):
@@ -1467,16 +1595,19 @@ class Plan(object):
1467
1595
  row1.addElem(_q2(self.C["x"], i, n2, self.N_i, self.N_n), -1)
1468
1596
  row2.addElem(_q2(self.C["x"], i, n2, self.N_i, self.N_n), +1)
1469
1597
 
1598
+ # Dividends and interest gains for year n2.
1470
1599
  afac = (self.mu*self.alpha_ijkn[i, 0, 0, n2]
1471
1600
  + np.sum(self.alpha_ijkn[i, 0, 1:, n2]*self.tau_kn[1:, n2]))
1472
- afac = 0
1601
+
1473
1602
  row1.addElem(_q3(self.C["b"], i, 0, n2, self.N_i, self.N_j, self.N_n + 1), -afac)
1474
1603
  row2.addElem(_q3(self.C["b"], i, 0, n2, self.N_i, self.N_j, self.N_n + 1), +afac)
1475
1604
 
1476
1605
  row1.addElem(_q2(self.C["d"], i, n2, self.N_i, self.N_n), -afac)
1477
1606
  row2.addElem(_q2(self.C["d"], i, n2, self.N_i, self.N_n), +afac)
1478
1607
 
1608
+ # Capital gains on stocks sold from taxable account accrued in year n2 - 1.
1479
1609
  bfac = self.alpha_ijkn[i, 0, 0, n2] * max(0, self.tau_kn[0, max(0, n2-1)])
1610
+
1480
1611
  row1.addElem(_q3(self.C["w"], i, 0, n2, self.N_i, self.N_j, self.N_n), +afac - bfac)
1481
1612
  row2.addElem(_q3(self.C["w"], i, 0, n2, self.N_i, self.N_j, self.N_n), -afac + bfac)
1482
1613
 
@@ -1733,6 +1864,9 @@ class Plan(object):
1733
1864
  self._adjustParameters(self.gamma_n, self.MAGI_n)
1734
1865
  self._buildOffsetMap(options)
1735
1866
 
1867
+ # Process debts and fixed assets
1868
+ self.processDebtsAndFixedAssets()
1869
+
1736
1870
  solver = myoptions.get("solver", self.defaultSolver)
1737
1871
  if solver not in knownSolvers:
1738
1872
  raise ValueError(f"Unknown solver {solver}.")
@@ -1881,7 +2015,7 @@ class Plan(object):
1881
2015
  elif vkeys[i] == "fx":
1882
2016
  x += [pulp.LpVariable(f"x_{i}", cat="Continuous", lowBound=Lb[i], upBound=Ub[i])]
1883
2017
  else:
1884
- raise RuntimeError(f"Internal error: Variable with wierd bound f{vkeys[i]}.")
2018
+ raise RuntimeError(f"Internal error: Variable with weird bound {vkeys[i]}.")
1885
2019
 
1886
2020
  x.extend([pulp.LpVariable(f"z_{i}", cat="Binary") for i in range(self.nbins)])
1887
2021
 
@@ -1979,26 +2113,6 @@ class Plan(object):
1979
2113
 
1980
2114
  return solution, xx, solverSuccess, solverMsg
1981
2115
 
1982
- def _computeNIIT(self, MAGI_n, I_n, Q_n):
1983
- """
1984
- Compute ACA tax on Dividends (Q) and Interests (I).
1985
- Pass arguments to better understand dependencies.
1986
- For accounting for rent and/or trust income, one can easily add a column
1987
- to the Wages and Contributions file and add yearly amount to Q_n + I_n below.
1988
- """
1989
- J_n = np.zeros(self.N_n)
1990
- status = len(self.yobs) - 1
1991
-
1992
- for n in range(self.N_n):
1993
- if status and n == self.n_d:
1994
- status -= 1
1995
-
1996
- Gmax = tx.niitThreshold[status]
1997
- if MAGI_n[n] > Gmax:
1998
- J_n[n] = tx.niitRate * min(MAGI_n[n] - Gmax, I_n[n] + Q_n[n])
1999
-
2000
- return J_n
2001
-
2002
2116
  def _computeNLstuff(self, x, includeMedicare):
2003
2117
  """
2004
2118
  Compute MAGI, Medicare costs, long-term capital gain tax rate, and
@@ -2013,7 +2127,7 @@ class Plan(object):
2013
2127
 
2014
2128
  self._aggregateResults(x, short=True)
2015
2129
 
2016
- 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)
2017
2131
  self.psi_n = tx.capitalGainTaxRate(self.N_i, self.MAGI_n, self.gamma_n[:-1], self.n_d, self.N_n)
2018
2132
  # Compute Medicare through self-consistent loop.
2019
2133
  if includeMedicare:
@@ -2094,6 +2208,8 @@ class Plan(object):
2094
2208
  * self.alpha_ijkn[:, 0, 0, :Nn],
2095
2209
  axis=0,
2096
2210
  )
2211
+ # Add fixed assets capital gains
2212
+ self.Q_n += self.fixed_assets_capital_gains_n
2097
2213
  self.U_n = self.psi_n * self.Q_n
2098
2214
 
2099
2215
  self.MAGI_n = self.G_n + self.e_n + self.Q_n
@@ -2102,7 +2218,7 @@ class Plan(object):
2102
2218
  * np.sum(self.alpha_ijkn[:, 0, 1:, :Nn] * self.tau_kn[1:, :], axis=1))
2103
2219
  self.I_n = np.sum(I_in, axis=0)
2104
2220
 
2105
- # Stop after building minimu required for self-consistent loop.
2221
+ # Stop after building minimum required for self-consistent loop.
2106
2222
  if short:
2107
2223
  return
2108
2224
 
@@ -2165,6 +2281,34 @@ class Plan(object):
2165
2281
  sources["RothX"] = self.x_in
2166
2282
  sources["tax-free wdrwl"] = self.w_ijn[:, 2, :]
2167
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
2168
2312
 
2169
2313
  savings = {}
2170
2314
  savings["taxable"] = self.b_ijn[:, 0, :]
@@ -2176,7 +2320,9 @@ class Plan(object):
2176
2320
 
2177
2321
  estate_j = np.sum(self.b_ijn[:, :, self.N_n], axis=0)
2178
2322
  estate_j[1] *= 1 - self.nu
2179
- 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]
2180
2326
 
2181
2327
  self.basis = self.g_n[0] / self.xi_n[0]
2182
2328
 
@@ -2263,7 +2409,10 @@ class Plan(object):
2263
2409
  for t in range(self.N_t):
2264
2410
  taxPaid = np.sum(self.T_tn[t], axis=0)
2265
2411
  taxPaidNow = np.sum(self.T_tn[t] / self.gamma_n[:-1], axis=0)
2266
- tname = tx.taxBracketNames[t]
2412
+ if t >= len(tx.taxBracketNames):
2413
+ tname = f"Bracket {t}"
2414
+ else:
2415
+ tname = tx.taxBracketNames[t]
2267
2416
  dic[f"» Subtotal in tax bracket {tname}"] = f"{u.d(taxPaidNow)}"
2268
2417
  dic[f"» [Subtotal in tax bracket {tname}]"] = f"{u.d(taxPaid)}"
2269
2418
 
@@ -2287,6 +2436,12 @@ class Plan(object):
2287
2436
  dic[" Total Medicare premiums paid"] = f"{u.d(taxPaidNow)}"
2288
2437
  dic["[Total Medicare premiums paid]"] = f"{u.d(taxPaid)}"
2289
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
+
2290
2445
  if self.N_i == 2 and self.n_d < self.N_n:
2291
2446
  p_j = self.partialEstate_j * (1 - self.phi_j)
2292
2447
  p_j[1] *= 1 - self.nu
@@ -2321,9 +2476,16 @@ class Plan(object):
2321
2476
  estate[1] *= 1 - self.nu
2322
2477
  endyear = self.year_n[-1]
2323
2478
  lyNow = 1./self.gamma_n[-1]
2324
- 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
2325
2482
  dic["Year of final bequest"] = (f"{endyear}")
2326
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)}")
2327
2489
  dic["[Total value of final bequest]"] = (f"{u.d(totEstate)}")
2328
2490
  dic["» Post-tax final bequest account value - taxable"] = (f"{u.d(lyNow*estate[0])}")
2329
2491
  dic["» [Post-tax final bequest account value - taxable]"] = (f"{u.d(estate[0])}")
@@ -2331,6 +2493,8 @@ class Plan(object):
2331
2493
  dic["» [Post-tax final bequest account value - tax-def]"] = (f"{u.d(estate[1])}")
2332
2494
  dic["» Post-tax final bequest account value - tax-free"] = (f"{u.d(lyNow*estate[2])}")
2333
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)}")
2334
2498
 
2335
2499
  dic["Plan starting date"] = str(self.startDate)
2336
2500
  dic["Cumulative inflation factor at end of final year"] = (f"{self.gamma_n[-1]:.2f}")
@@ -2713,6 +2877,20 @@ class Plan(object):
2713
2877
  ws.append(lastRow)
2714
2878
  _formatSpreadsheet(ws, "currency")
2715
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
+
2716
2894
  # Allocations.
2717
2895
  jDic = {"taxable": 0, "tax-deferred": 1, "tax-free": 2}
2718
2896
  kDic = {"stocks": 0, "C bonds": 1, "T notes": 2, "common": 3}
@@ -2894,3 +3072,93 @@ def _formatSpreadsheet(ws, ftype):
2894
3072
  cell.number_format = fstring
2895
3073
 
2896
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,
owlplanner/progress.py CHANGED
@@ -7,18 +7,62 @@ Disclaimers: This code is for educational purposes only and does not constitute
7
7
 
8
8
  """
9
9
 
10
+ from typing import Optional
10
11
  from owlplanner import utils as u
11
12
 
12
13
 
13
- class Progress(object):
14
- def __init__(self, mylog):
14
+ class Progress:
15
+ """
16
+ A simple progress indicator for long-running operations.
17
+
18
+ Displays progress as a percentage (0-100%) on a single line that updates
19
+ in place using carriage return.
20
+
21
+ Example:
22
+ prog = Progress(mylog)
23
+ prog.start()
24
+ for i in range(100):
25
+ prog.show(i / 100)
26
+ prog.finish()
27
+ """
28
+
29
+ def __init__(self, mylog: Optional[object] = None):
30
+ """
31
+ Initialize the progress indicator.
32
+
33
+ Args:
34
+ mylog: Logger object with a print() method. If None, progress
35
+ updates will be silently ignored (useful for Streamlit UI).
36
+ """
15
37
  self.mylog = mylog
16
38
 
17
39
  def start(self):
18
- self.mylog.print("|--- progress ---|")
40
+ """
41
+ Display the progress header.
42
+ """
43
+ if self.mylog is not None:
44
+ self.mylog.print("|--- progress ---|")
45
+
46
+ def show(self, x: float):
47
+ """
48
+ Display the current progress percentage.
49
+
50
+ Args:
51
+ x: Progress value between 0.0 and 1.0 (will be clamped to this range).
52
+ Values outside this range will be clamped.
53
+ """
54
+ if self.mylog is None:
55
+ return
56
+
57
+ # Clamp x to [0, 1] range
58
+ x = max(0.0, min(1.0, x))
19
59
 
20
- def show(self, x):
21
- self.mylog.print(f"\r\r{u.pc(x, f=0)}", end="")
60
+ # Use single \r for carriage return (double \r\r is unnecessary)
61
+ self.mylog.print(f"\r{u.pc(x, f=0)}", end="")
22
62
 
23
63
  def finish(self):
24
- self.mylog.print()
64
+ """
65
+ Finish the progress display by printing a newline.
66
+ """
67
+ if self.mylog is not None:
68
+ self.mylog.print()
owlplanner/rates.py CHANGED
@@ -238,7 +238,7 @@ class Rates(object):
238
238
  if corrarr.shape == (Nk, Nk):
239
239
  pass
240
240
  # Only off-diagonal elements were provided: build full matrix.
241
- elif corrarr.shape == ((Nk * Nk - Nk) / 2,):
241
+ elif corrarr.shape == ((Nk * (Nk - 1)) // 2,):
242
242
  newcorr = np.identity(Nk)
243
243
  x = 0
244
244
  for i in range(Nk):