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/abcapi.py +6 -6
- owlplanner/config.py +22 -26
- owlplanner/data/awi.csv +75 -0
- owlplanner/data/bendpoints.csv +49 -0
- owlplanner/data/newawi.csv +75 -0
- owlplanner/debts.py +287 -0
- owlplanner/fixedassets.py +214 -0
- owlplanner/plan.py +316 -48
- owlplanner/plotting/plotly_backend.py +1 -1
- owlplanner/progress.py +50 -6
- owlplanner/rates.py +1 -1
- owlplanner/socialsecurity.py +126 -15
- owlplanner/tax2025.py +20 -0
- owlplanner/tax2026.py +61 -27
- owlplanner/timelists.py +127 -19
- owlplanner/utils.py +25 -1
- owlplanner/version.py +1 -1
- {owlplanner-2025.12.5.dist-info → owlplanner-2025.12.20.dist-info}/METADATA +41 -157
- owlplanner-2025.12.20.dist-info/RECORD +29 -0
- owlplanner-2025.12.5.dist-info/RECORD +0 -24
- {owlplanner-2025.12.5.dist-info → owlplanner-2025.12.20.dist-info}/WHEEL +0 -0
- {owlplanner-2025.12.5.dist-info → owlplanner-2025.12.20.dist-info}/licenses/LICENSE +0 -0
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
|
|
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,
|
|
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(
|
|
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
|
|
272
|
-
self.
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
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 - (
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
21
|
-
self.mylog.print(f"\r
|
|
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
|
-
|
|
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 -
|
|
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):
|