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/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 +324 -55
- 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.3.dist-info → owlplanner-2025.12.20.dist-info}/METADATA +41 -157
- owlplanner-2025.12.20.dist-info/RECORD +29 -0
- owlplanner-2025.12.3.dist-info/RECORD +0 -24
- {owlplanner-2025.12.3.dist-info → owlplanner-2025.12.20.dist-info}/WHEEL +0 -0
- {owlplanner-2025.12.3.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):
|
|
@@ -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
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
#
|
|
1180
|
-
#
|
|
1181
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|