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/abcapi.py
CHANGED
|
@@ -25,10 +25,10 @@ Disclaimers: This code is for educational purposes only and does not constitute
|
|
|
25
25
|
import numpy as np
|
|
26
26
|
|
|
27
27
|
|
|
28
|
-
class Row
|
|
28
|
+
class Row:
|
|
29
29
|
"""
|
|
30
|
-
Solver-neutral API to
|
|
31
|
-
A Row
|
|
30
|
+
Solver-neutral API to accommodate Mosek/HiGHS.
|
|
31
|
+
A Row represents a row in matrix A.
|
|
32
32
|
"""
|
|
33
33
|
|
|
34
34
|
def __init__(self, nvars):
|
|
@@ -58,7 +58,7 @@ class Row(object):
|
|
|
58
58
|
return self
|
|
59
59
|
|
|
60
60
|
|
|
61
|
-
class ConstraintMatrix
|
|
61
|
+
class ConstraintMatrix:
|
|
62
62
|
"""
|
|
63
63
|
Solver-neutral API for expressing constraints.
|
|
64
64
|
"""
|
|
@@ -143,7 +143,7 @@ class ConstraintMatrix(object):
|
|
|
143
143
|
return Alu, lb, ub
|
|
144
144
|
|
|
145
145
|
|
|
146
|
-
class Bounds
|
|
146
|
+
class Bounds:
|
|
147
147
|
"""
|
|
148
148
|
Solver-neutral API for bounds on variables.
|
|
149
149
|
"""
|
|
@@ -214,7 +214,7 @@ class Bounds(object):
|
|
|
214
214
|
return self.integrality
|
|
215
215
|
|
|
216
216
|
|
|
217
|
-
class Objective
|
|
217
|
+
class Objective:
|
|
218
218
|
"""
|
|
219
219
|
Solver-neutral objective function.
|
|
220
220
|
"""
|
owlplanner/config.py
CHANGED
|
@@ -10,7 +10,7 @@ Disclaimers: This code is for educational purposes only and does not constitute
|
|
|
10
10
|
|
|
11
11
|
"""
|
|
12
12
|
|
|
13
|
-
import toml
|
|
13
|
+
import toml
|
|
14
14
|
from io import StringIO, BytesIO
|
|
15
15
|
import numpy as np
|
|
16
16
|
from datetime import date
|
|
@@ -21,12 +21,13 @@ from owlplanner import mylogging as log
|
|
|
21
21
|
from owlplanner.rates import FROM, TO
|
|
22
22
|
|
|
23
23
|
|
|
24
|
+
AccountTypes = ["taxable", "tax-deferred", "tax-free"]
|
|
25
|
+
|
|
26
|
+
|
|
24
27
|
def saveConfig(myplan, file, mylog):
|
|
25
28
|
"""
|
|
26
29
|
Save case parameters and return a dictionary containing all parameters.
|
|
27
30
|
"""
|
|
28
|
-
# np.set_printoptions(legacy='1.21')
|
|
29
|
-
accountTypes = ["taxable", "tax-deferred", "tax-free"]
|
|
30
31
|
|
|
31
32
|
diconf = {}
|
|
32
33
|
diconf["Plan Name"] = myplan._name
|
|
@@ -36,8 +37,7 @@ def saveConfig(myplan, file, mylog):
|
|
|
36
37
|
diconf["Basic Info"] = {
|
|
37
38
|
"Status": ["unknown", "single", "married"][myplan.N_i],
|
|
38
39
|
"Names": myplan.inames,
|
|
39
|
-
"
|
|
40
|
-
"Birth month": myplan.mobs.tolist(),
|
|
40
|
+
"Date of birth": myplan.dobs,
|
|
41
41
|
"Life expectancy": myplan.expectancy.tolist(),
|
|
42
42
|
"Start date": myplan.startDate,
|
|
43
43
|
}
|
|
@@ -46,13 +46,13 @@ def saveConfig(myplan, file, mylog):
|
|
|
46
46
|
diconf["Assets"] = {}
|
|
47
47
|
for j in range(myplan.N_j):
|
|
48
48
|
amounts = myplan.beta_ij[:, j] / 1000
|
|
49
|
-
diconf["Assets"][f"{
|
|
49
|
+
diconf["Assets"][f"{AccountTypes[j]} savings balances"] = amounts.tolist()
|
|
50
50
|
if myplan.N_i == 2:
|
|
51
51
|
diconf["Assets"]["Beneficiary fractions"] = myplan.phi_j.tolist()
|
|
52
52
|
diconf["Assets"]["Spousal surplus deposit fraction"] = myplan.eta
|
|
53
53
|
|
|
54
|
-
#
|
|
55
|
-
diconf["
|
|
54
|
+
# Household Financial Profile
|
|
55
|
+
diconf["Household Financial Profile"] = {"HFP file name": myplan.timeListsFileName}
|
|
56
56
|
|
|
57
57
|
# Fixed Income.
|
|
58
58
|
diconf["Fixed Income"] = {
|
|
@@ -90,7 +90,7 @@ def saveConfig(myplan, file, mylog):
|
|
|
90
90
|
"Type": myplan.ARCoord,
|
|
91
91
|
}
|
|
92
92
|
if myplan.ARCoord == "account":
|
|
93
|
-
for accType in
|
|
93
|
+
for accType in AccountTypes:
|
|
94
94
|
diconf["Asset Allocation"][accType] = myplan.boundsAR[accType]
|
|
95
95
|
else:
|
|
96
96
|
diconf["Asset Allocation"]["generic"] = myplan.boundsAR["generic"]
|
|
@@ -146,8 +146,6 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
|
|
|
146
146
|
"""
|
|
147
147
|
mylog = log.Logger(verbose, logstreams)
|
|
148
148
|
|
|
149
|
-
accountTypes = ["taxable", "tax-deferred", "tax-free"]
|
|
150
|
-
|
|
151
149
|
dirname = ""
|
|
152
150
|
if isinstance(file, str):
|
|
153
151
|
filename = file
|
|
@@ -180,21 +178,19 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
|
|
|
180
178
|
# Basic Info.
|
|
181
179
|
name = diconf["Plan Name"]
|
|
182
180
|
inames = diconf["Basic Info"]["Names"]
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
# Default to January if no month entry found.
|
|
187
|
-
mobs = diconf["Basic Info"].get("Birth month", [1]*icount)
|
|
181
|
+
icount = len(inames)
|
|
182
|
+
# Default to January 15, 1965 if no entry is found.
|
|
183
|
+
dobs = diconf["Basic Info"].get("Date of birth", ["1965-01-15"]*icount)
|
|
188
184
|
expectancy = diconf["Basic Info"]["Life expectancy"]
|
|
189
185
|
s = ["", "s"][icount - 1]
|
|
190
186
|
mylog.vprint(f"Plan for {icount} individual{s}: {inames}.")
|
|
191
|
-
p = plan.Plan(inames,
|
|
187
|
+
p = plan.Plan(inames, dobs, expectancy, name, verbose=True, logstreams=logstreams)
|
|
192
188
|
p._description = diconf.get("Description", "")
|
|
193
189
|
|
|
194
190
|
# Assets.
|
|
195
191
|
startDate = diconf["Basic Info"].get("Start date", "today")
|
|
196
192
|
balances = {}
|
|
197
|
-
for acc in
|
|
193
|
+
for acc in AccountTypes:
|
|
198
194
|
balances[acc] = diconf["Assets"][f"{acc} savings balances"]
|
|
199
195
|
p.setAccountBalances(taxable=balances["taxable"], taxDeferred=balances["tax-deferred"],
|
|
200
196
|
taxFree=balances["tax-free"], startDate=startDate)
|
|
@@ -204,14 +200,14 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
|
|
|
204
200
|
eta = diconf["Assets"]["Spousal surplus deposit fraction"]
|
|
205
201
|
p.setSpousalDepositFraction(eta)
|
|
206
202
|
|
|
207
|
-
#
|
|
208
|
-
timeListsFileName = diconf["
|
|
203
|
+
# Household Financial Profile
|
|
204
|
+
timeListsFileName = diconf["Household Financial Profile"]["HFP file name"]
|
|
209
205
|
if timeListsFileName != "None":
|
|
210
206
|
if readContributions:
|
|
211
207
|
if os.path.exists(timeListsFileName):
|
|
212
208
|
myfile = timeListsFileName
|
|
213
|
-
elif dirname != "" and os.path.exists(dirname
|
|
214
|
-
myfile = dirname
|
|
209
|
+
elif dirname != "" and os.path.exists(os.path.join(dirname, timeListsFileName)):
|
|
210
|
+
myfile = os.path.join(dirname, timeListsFileName)
|
|
215
211
|
else:
|
|
216
212
|
raise FileNotFoundError(f"File '{timeListsFileName}' not found.")
|
|
217
213
|
p.readContributions(myfile)
|
|
@@ -243,7 +239,7 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
|
|
|
243
239
|
frm = diconf["Rates Selection"]["From"]
|
|
244
240
|
if not isinstance(frm, int):
|
|
245
241
|
frm = int(frm)
|
|
246
|
-
to =
|
|
242
|
+
to = diconf["Rates Selection"]["To"]
|
|
247
243
|
if not isinstance(to, int):
|
|
248
244
|
to = int(to)
|
|
249
245
|
if rateMethod in ["user", "stochastic"]:
|
|
@@ -262,7 +258,7 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
|
|
|
262
258
|
)
|
|
263
259
|
allocType = diconf["Asset Allocation"]["Type"]
|
|
264
260
|
if allocType == "account":
|
|
265
|
-
for aType in
|
|
261
|
+
for aType in AccountTypes:
|
|
266
262
|
boundsAR[aType] = np.array(diconf["Asset Allocation"][aType], dtype=np.float32)
|
|
267
263
|
|
|
268
264
|
p.setAllocationRatios(
|
|
@@ -300,7 +296,7 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
|
|
|
300
296
|
p.solverOptions = diconf["Solver Options"]
|
|
301
297
|
|
|
302
298
|
# Address legacy case files.
|
|
303
|
-
if diconf["Solver Options"].get("withMedicare"
|
|
299
|
+
if diconf["Solver Options"].get("withMedicare"):
|
|
304
300
|
p.solverOptions["withMedicare"] = "loop"
|
|
305
301
|
|
|
306
302
|
# Check consistency of noRothConversions.
|
|
@@ -311,7 +307,7 @@ def readConfig(file, *, verbose=True, logstreams=None, readContributions=True):
|
|
|
311
307
|
# Rebase startRothConversions on year change.
|
|
312
308
|
thisyear = date.today().year
|
|
313
309
|
year = p.solverOptions.get("startRothConversions", thisyear)
|
|
314
|
-
|
|
310
|
+
p.solverOptions["startRothConversions"] = max(year, thisyear)
|
|
315
311
|
|
|
316
312
|
# Results.
|
|
317
313
|
p.setDefaultPlots(diconf["Results"]["Default plots"])
|
owlplanner/data/awi.csv
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
year,AWI,percent
|
|
2
|
+
1951, 2799.16 ,—
|
|
3
|
+
1952, 2973.32 ,6.22
|
|
4
|
+
1953, 3139.44 ,5.59
|
|
5
|
+
1954, 3155.64 ,0.52
|
|
6
|
+
1955, 3301.44 ,4.62
|
|
7
|
+
1956, 3532.36 ,6.99
|
|
8
|
+
1957, 3641.72 ,3.10
|
|
9
|
+
1958, 3673.80 ,0.88
|
|
10
|
+
1959, 3855.80 ,4.95
|
|
11
|
+
1960, 4007.12 ,3.92
|
|
12
|
+
1961, 4086.76 ,1.99
|
|
13
|
+
1962, 4291.40 ,5.01
|
|
14
|
+
1963, 4396.64 ,2.45
|
|
15
|
+
1964, 4576.32 ,4.09
|
|
16
|
+
1965, 4658.72 ,1.80
|
|
17
|
+
1966, 4938.36 ,6.00
|
|
18
|
+
1967, 5213.44 ,5.57
|
|
19
|
+
1968, 5571.76 ,6.87
|
|
20
|
+
1969, 5893.76 ,5.78
|
|
21
|
+
1970, 6186.24 ,4.96
|
|
22
|
+
1971, 6497.08 ,5.02
|
|
23
|
+
1972, 7133.80 ,9.80
|
|
24
|
+
1973, 7580.16 ,6.26
|
|
25
|
+
1974, 8030.76 ,5.94
|
|
26
|
+
1975, 8630.92 ,7.47
|
|
27
|
+
1976, 9226.48 ,6.90
|
|
28
|
+
1977, 9779.44 ,5.99
|
|
29
|
+
1978, 10556.03 ,7.94
|
|
30
|
+
1979, 11479.46 ,8.75
|
|
31
|
+
1980, 12513.46 ,9.01
|
|
32
|
+
1981, 13773.10 ,10.07
|
|
33
|
+
1982, 14531.34 ,5.51
|
|
34
|
+
1983, 15239.24 ,4.87
|
|
35
|
+
1984, 16135.07 ,5.88
|
|
36
|
+
1985, 16822.51 ,4.26
|
|
37
|
+
1986, 17321.82 ,2.97
|
|
38
|
+
1987, 18426.51 ,6.38
|
|
39
|
+
1988, 19334.04 ,4.93
|
|
40
|
+
1989, 20099.55 ,3.96
|
|
41
|
+
1990, 21027.98 ,4.62
|
|
42
|
+
1991, 21811.60 ,3.73
|
|
43
|
+
1992, 22935.42 ,5.15
|
|
44
|
+
1993, 23132.67 ,0.86
|
|
45
|
+
1994, 23753.53 ,2.68
|
|
46
|
+
1995, 24705.66 ,4.01
|
|
47
|
+
1996, 25913.90 ,4.89
|
|
48
|
+
1997, 27426.00 ,5.84
|
|
49
|
+
1998, 28861.44 ,5.23
|
|
50
|
+
1999, 30469.84 ,5.57
|
|
51
|
+
2000, 32154.82 ,5.53
|
|
52
|
+
2001, 32921.92 ,2.39
|
|
53
|
+
2002, 33252.09 ,1.00
|
|
54
|
+
2003, 34064.95 ,2.44
|
|
55
|
+
2004, 35648.55 ,4.65
|
|
56
|
+
2005, 36952.94 ,3.66
|
|
57
|
+
2006, 38651.41 ,4.60
|
|
58
|
+
2007, 40405.48 ,4.54
|
|
59
|
+
2008, 41334.97 ,2.30
|
|
60
|
+
2009, 40711.61 ,-1.51
|
|
61
|
+
2010, 41673.83 ,2.36
|
|
62
|
+
2011, 42979.61 ,3.13
|
|
63
|
+
2012, 44321.67 ,3.12
|
|
64
|
+
2013, 44888.16 ,1.28
|
|
65
|
+
2014, 46481.52 ,3.55
|
|
66
|
+
2015, 48098.63 ,3.48
|
|
67
|
+
2016, 48642.15 ,1.13
|
|
68
|
+
2017, 50321.89 ,3.45
|
|
69
|
+
2018, 52145.80 ,3.62
|
|
70
|
+
2019, 54099.99 ,3.75
|
|
71
|
+
2020, 55628.60 ,2.83
|
|
72
|
+
2021, 60575.07 ,8.89
|
|
73
|
+
2022, 63795.13 ,5.32
|
|
74
|
+
2023, 66621.80 ,4.43
|
|
75
|
+
2024, 69846.57 ,4.84
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
year first second
|
|
2
|
+
1979 180 1085
|
|
3
|
+
1980 194 1171
|
|
4
|
+
1981 211 1274
|
|
5
|
+
1982 230 1388
|
|
6
|
+
1983 254 1528
|
|
7
|
+
1984 267 1612
|
|
8
|
+
1985 280 1691
|
|
9
|
+
1986 297 1790
|
|
10
|
+
1987 310 1866
|
|
11
|
+
1988 319 1922
|
|
12
|
+
1989 339 2044
|
|
13
|
+
1990 356 2145
|
|
14
|
+
1991 370 2230
|
|
15
|
+
1992 387 2333
|
|
16
|
+
1993 401 2420
|
|
17
|
+
1994 422 2545
|
|
18
|
+
1995 426 2567
|
|
19
|
+
1996 437 2635
|
|
20
|
+
1997 455 2741
|
|
21
|
+
1998 477 2875
|
|
22
|
+
1999 505 3043
|
|
23
|
+
2000 531 3202
|
|
24
|
+
2001 561 3381
|
|
25
|
+
2002 592 3567
|
|
26
|
+
2003 606 3653
|
|
27
|
+
2004 612 3689
|
|
28
|
+
2005 627 3779
|
|
29
|
+
2006 656 3955
|
|
30
|
+
2007 680 4100
|
|
31
|
+
2008 711 4288
|
|
32
|
+
2009 744 4483
|
|
33
|
+
2010 761 4586
|
|
34
|
+
2011 749 4517
|
|
35
|
+
2012 767 4624
|
|
36
|
+
2013 791 4768
|
|
37
|
+
2014 816 4917
|
|
38
|
+
2015 826 4980
|
|
39
|
+
2016 856 5157
|
|
40
|
+
2017 885 5336
|
|
41
|
+
2018 895 5397
|
|
42
|
+
2019 926 5583
|
|
43
|
+
2020 960 5785
|
|
44
|
+
2021 996 6002
|
|
45
|
+
2022 1024 6172
|
|
46
|
+
2023 1115 6721
|
|
47
|
+
2024 1174 7078
|
|
48
|
+
2025 1226 7391
|
|
49
|
+
2026 1286 7749
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
year,AWI,percent
|
|
2
|
+
1951, 2799.16 ,—
|
|
3
|
+
1952, 2973.32 ,6.22
|
|
4
|
+
1953, 3139.44 ,5.59
|
|
5
|
+
1954, 3155.64 ,0.52
|
|
6
|
+
1955, 3301.44 ,4.62
|
|
7
|
+
1956, 3532.36 ,6.99
|
|
8
|
+
1957, 3641.72 ,3.10
|
|
9
|
+
1958, 3673.80 ,0.88
|
|
10
|
+
1959, 3855.80 ,4.95
|
|
11
|
+
1960, 4007.12 ,3.92
|
|
12
|
+
1961, 4086.76 ,1.99
|
|
13
|
+
1962, 4291.40 ,5.01
|
|
14
|
+
1963, 4396.64 ,2.45
|
|
15
|
+
1964, 4576.32 ,4.09
|
|
16
|
+
1965, 4658.72 ,1.80
|
|
17
|
+
1966, 4938.36 ,6.00
|
|
18
|
+
1967, 5213.44 ,5.57
|
|
19
|
+
1968, 5571.76 ,6.87
|
|
20
|
+
1969, 5893.76 ,5.78
|
|
21
|
+
1970, 6186.24 ,4.96
|
|
22
|
+
1971, 6497.08 ,5.02
|
|
23
|
+
1972, 7133.80 ,9.80
|
|
24
|
+
1973, 7580.16 ,6.26
|
|
25
|
+
1974, 8030.76 ,5.94
|
|
26
|
+
1975, 8630.92 ,7.47
|
|
27
|
+
1976, 9226.48 ,6.90
|
|
28
|
+
1977, 9779.44 ,5.99
|
|
29
|
+
1978, 10556.03 ,7.94
|
|
30
|
+
1979, 11479.46 ,8.75
|
|
31
|
+
1980, 12513.46 ,9.01
|
|
32
|
+
1981, 13773.10 ,10.07
|
|
33
|
+
1982, 14531.34 ,5.51
|
|
34
|
+
1983, 15239.24 ,4.87
|
|
35
|
+
1984, 16135.07 ,5.88
|
|
36
|
+
1985, 16822.51 ,4.26
|
|
37
|
+
1986, 17321.82 ,2.97
|
|
38
|
+
1987, 18426.51 ,6.38
|
|
39
|
+
1988, 19334.04 ,4.93
|
|
40
|
+
1989, 20099.55 ,3.96
|
|
41
|
+
1990, 21027.98 ,4.62
|
|
42
|
+
1991, 21811.60 ,3.73
|
|
43
|
+
1992, 22935.42 ,5.15
|
|
44
|
+
1993, 23132.67 ,0.86
|
|
45
|
+
1994, 23753.53 ,2.68
|
|
46
|
+
1995, 24705.66 ,4.01
|
|
47
|
+
1996, 25913.90 ,4.89
|
|
48
|
+
1997, 27426.00 ,5.84
|
|
49
|
+
1998, 28861.44 ,5.23
|
|
50
|
+
1999, 30469.84 ,5.57
|
|
51
|
+
2000, 32154.82 ,5.53
|
|
52
|
+
2001, 32921.92 ,2.39
|
|
53
|
+
2002, 33252.09 ,1.00
|
|
54
|
+
2003, 34064.95 ,2.44
|
|
55
|
+
2004, 35648.55 ,4.65
|
|
56
|
+
2005, 36952.94 ,3.66
|
|
57
|
+
2006, 38651.41 ,4.60
|
|
58
|
+
2007, 40405.48 ,4.54
|
|
59
|
+
2008, 41334.97 ,2.30
|
|
60
|
+
2009, 40711.61 ,-1.51
|
|
61
|
+
2010, 41673.83 ,2.36
|
|
62
|
+
2011, 42979.61 ,3.13
|
|
63
|
+
2012, 44321.67 ,3.12
|
|
64
|
+
2013, 44888.16 ,1.28
|
|
65
|
+
2014, 46481.52 ,3.55
|
|
66
|
+
2015, 48098.63 ,3.48
|
|
67
|
+
2016, 48642.15 ,1.13
|
|
68
|
+
2017, 50321.89 ,3.45
|
|
69
|
+
2018, 52145.80 ,3.62
|
|
70
|
+
2019, 54099.99 ,3.75
|
|
71
|
+
2020, 55628.60 ,2.83
|
|
72
|
+
2021, 60575.07 ,8.89
|
|
73
|
+
2022, 63795.13 ,5.32
|
|
74
|
+
2023, 66621.80 ,4.43
|
|
75
|
+
2024, 69846.57 ,4.84
|
owlplanner/debts.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""
|
|
2
|
+
|
|
3
|
+
Owl/debts:r
|
|
4
|
+
|
|
5
|
+
This file contains functions for handling debts.
|
|
6
|
+
|
|
7
|
+
Copyright © 2025 - Martin-D. Lacasse
|
|
8
|
+
|
|
9
|
+
Disclaimers: This code is for educational purposes only and does not constitute financial advice.
|
|
10
|
+
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
######################################################################
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pandas as pd # noqa: F401
|
|
16
|
+
from datetime import date
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def calculate_monthly_payment(principal, annual_rate, term_years):
|
|
20
|
+
"""
|
|
21
|
+
Calculate monthly payment for an amortizing loan. This is a constant payment amount for a fixed-rate loan.
|
|
22
|
+
monthly payment = principal * (monthly_rate * (1 + monthly_rate)**num_payments) /
|
|
23
|
+
((1 + monthly_rate)**num_payments - 1)
|
|
24
|
+
where monthly_rate = annual_rate / 100.0 / 12.0 and num_payments = term_years * 12.0.
|
|
25
|
+
|
|
26
|
+
This formula is derived from the formula for the present value of an annuity.
|
|
27
|
+
|
|
28
|
+
Parameters:
|
|
29
|
+
-----------
|
|
30
|
+
principal : float
|
|
31
|
+
Original loan amount
|
|
32
|
+
annual_rate : float
|
|
33
|
+
Annual interest rate as a percentage (e.g., 4.5 for 4.5%)
|
|
34
|
+
term_years : int
|
|
35
|
+
Loan term in years
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
--------
|
|
39
|
+
float
|
|
40
|
+
Monthly payment amount
|
|
41
|
+
"""
|
|
42
|
+
if term_years <= 0 or annual_rate < 0 or principal <= 0:
|
|
43
|
+
return 0.0
|
|
44
|
+
|
|
45
|
+
monthly_rate = annual_rate / 100.0 / 12.0
|
|
46
|
+
num_payments = term_years * 12
|
|
47
|
+
fac = (1 + monthly_rate)**num_payments
|
|
48
|
+
|
|
49
|
+
if monthly_rate == 0:
|
|
50
|
+
return principal / num_payments
|
|
51
|
+
|
|
52
|
+
payment = principal * (monthly_rate * fac) / (fac - 1)
|
|
53
|
+
|
|
54
|
+
return payment
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def calculate_annual_payment(principal, annual_rate, term_years):
|
|
58
|
+
"""
|
|
59
|
+
Calculate annual payment for an amortizing loan.
|
|
60
|
+
|
|
61
|
+
Parameters:
|
|
62
|
+
-----------
|
|
63
|
+
principal : float
|
|
64
|
+
Original loan amount
|
|
65
|
+
annual_rate : float
|
|
66
|
+
Annual interest rate as a percentage
|
|
67
|
+
term_years : int
|
|
68
|
+
Loan term in years
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
--------
|
|
72
|
+
float
|
|
73
|
+
Annual payment amount
|
|
74
|
+
"""
|
|
75
|
+
return 12 * calculate_monthly_payment(principal, annual_rate, term_years)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def calculate_remaining_balance(principal, annual_rate, term_years, years_elapsed):
|
|
79
|
+
"""
|
|
80
|
+
Calculate remaining balance on a loan after a given number of years.
|
|
81
|
+
|
|
82
|
+
Parameters:
|
|
83
|
+
-----------
|
|
84
|
+
principal : float
|
|
85
|
+
Original loan amount
|
|
86
|
+
annual_rate : float
|
|
87
|
+
Annual interest rate as a percentage
|
|
88
|
+
term_years : int
|
|
89
|
+
Original loan term in years
|
|
90
|
+
years_elapsed : float
|
|
91
|
+
Number of years since loan origination
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
--------
|
|
95
|
+
float
|
|
96
|
+
Remaining balance
|
|
97
|
+
"""
|
|
98
|
+
if years_elapsed <= 0:
|
|
99
|
+
return principal
|
|
100
|
+
|
|
101
|
+
if years_elapsed >= term_years:
|
|
102
|
+
return 0.0
|
|
103
|
+
|
|
104
|
+
monthly_rate = annual_rate / 100.0 / 12.0
|
|
105
|
+
fac = 1 + monthly_rate
|
|
106
|
+
num_payments = term_years * 12
|
|
107
|
+
payments_made = int(years_elapsed * 12)
|
|
108
|
+
|
|
109
|
+
if monthly_rate == 0:
|
|
110
|
+
return principal * (1 - payments_made / num_payments)
|
|
111
|
+
|
|
112
|
+
remaining = principal * (fac**num_payments - fac**payments_made) / (fac**num_payments - 1)
|
|
113
|
+
|
|
114
|
+
return max(0.0, remaining)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def get_debt_payments_for_year(debts_df, year):
|
|
118
|
+
"""
|
|
119
|
+
Calculate total debt payments (principal + interest) for a given year.
|
|
120
|
+
|
|
121
|
+
Parameters:
|
|
122
|
+
-----------
|
|
123
|
+
debts_df : pd.DataFrame
|
|
124
|
+
DataFrame with columns: name, type, year, term, amount, rate
|
|
125
|
+
year : int
|
|
126
|
+
Year for which to calculate payments
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
--------
|
|
130
|
+
float
|
|
131
|
+
Total annual debt payments for the year
|
|
132
|
+
"""
|
|
133
|
+
if debts_df is None or debts_df.empty:
|
|
134
|
+
return 0.0
|
|
135
|
+
|
|
136
|
+
total_payments = 0.0
|
|
137
|
+
|
|
138
|
+
for _, debt in debts_df.iterrows():
|
|
139
|
+
start_year = int(debt["year"])
|
|
140
|
+
term = int(debt["term"])
|
|
141
|
+
end_year = start_year + term
|
|
142
|
+
|
|
143
|
+
# Check if loan is active in this year
|
|
144
|
+
if start_year <= year < end_year:
|
|
145
|
+
principal = float(debt["amount"])
|
|
146
|
+
rate = float(debt["rate"])
|
|
147
|
+
|
|
148
|
+
# Calculate annual payment (payment amount is constant for fixed-rate loans)
|
|
149
|
+
annual_payment = calculate_annual_payment(principal, rate, term)
|
|
150
|
+
total_payments += annual_payment
|
|
151
|
+
|
|
152
|
+
return total_payments
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def get_debt_balances_for_year(debts_df, year):
|
|
156
|
+
"""
|
|
157
|
+
Calculate total remaining debt balances at the end of a given year.
|
|
158
|
+
|
|
159
|
+
Parameters:
|
|
160
|
+
-----------
|
|
161
|
+
debts_df : pd.DataFrame
|
|
162
|
+
DataFrame with columns: name, type, year, term, amount, rate
|
|
163
|
+
year : int
|
|
164
|
+
Year for which to calculate balances
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
--------
|
|
168
|
+
float
|
|
169
|
+
Total remaining debt balances at end of year
|
|
170
|
+
"""
|
|
171
|
+
if debts_df is None or debts_df.empty:
|
|
172
|
+
return 0.0
|
|
173
|
+
|
|
174
|
+
total_balance = 0.0
|
|
175
|
+
|
|
176
|
+
for _, debt in debts_df.iterrows():
|
|
177
|
+
start_year = int(debt["year"])
|
|
178
|
+
term = int(debt["term"])
|
|
179
|
+
end_year = start_year + term
|
|
180
|
+
|
|
181
|
+
# Check if loan is active at end of this year
|
|
182
|
+
if start_year <= year < end_year:
|
|
183
|
+
principal = float(debt["amount"])
|
|
184
|
+
rate = float(debt["rate"])
|
|
185
|
+
|
|
186
|
+
# Calculate remaining balance at end of year
|
|
187
|
+
years_elapsed = year - start_year + 1
|
|
188
|
+
remaining_balance = calculate_remaining_balance(principal, rate, term, years_elapsed)
|
|
189
|
+
total_balance += remaining_balance
|
|
190
|
+
|
|
191
|
+
return total_balance
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def get_debt_payments_array(debts_df, N_n, thisyear=None):
|
|
195
|
+
"""
|
|
196
|
+
Process debts_df to provide a single array of length N_n containing
|
|
197
|
+
all annual payments made for each year of the plan.
|
|
198
|
+
|
|
199
|
+
Parameters:
|
|
200
|
+
-----------
|
|
201
|
+
debts_df : pd.DataFrame
|
|
202
|
+
DataFrame with columns: name, type, year, term, amount, rate
|
|
203
|
+
N_n : int
|
|
204
|
+
Number of years in the plan (length of output array)
|
|
205
|
+
thisyear : int, optional
|
|
206
|
+
Starting year of the plan (defaults to date.today().year).
|
|
207
|
+
Array index 0 corresponds to thisyear, index 1 to thisyear+1, etc.
|
|
208
|
+
|
|
209
|
+
Returns:
|
|
210
|
+
--------
|
|
211
|
+
np.ndarray
|
|
212
|
+
Array of length N_n with annual debt payments for each year.
|
|
213
|
+
payments_n[0] = payments for thisyear,
|
|
214
|
+
payments_n[1] = payments for thisyear+1, etc.
|
|
215
|
+
"""
|
|
216
|
+
if thisyear is None:
|
|
217
|
+
thisyear = date.today().year
|
|
218
|
+
|
|
219
|
+
if debts_df is None or debts_df.empty:
|
|
220
|
+
return np.zeros(N_n)
|
|
221
|
+
|
|
222
|
+
payments_n = np.zeros(N_n)
|
|
223
|
+
|
|
224
|
+
for _, debt in debts_df.iterrows():
|
|
225
|
+
start_year = int(debt["year"])
|
|
226
|
+
term = int(debt["term"])
|
|
227
|
+
end_year = start_year + term
|
|
228
|
+
principal = float(debt["amount"])
|
|
229
|
+
rate = float(debt["rate"])
|
|
230
|
+
|
|
231
|
+
# Calculate annual payment (payment amount is constant for fixed-rate loans)
|
|
232
|
+
annual_payment = calculate_annual_payment(principal, rate, term)
|
|
233
|
+
|
|
234
|
+
# Add payments for each year the loan is active within the plan horizon
|
|
235
|
+
for n in range(N_n):
|
|
236
|
+
year = thisyear + n
|
|
237
|
+
if start_year <= year < end_year:
|
|
238
|
+
payments_n[n] += annual_payment
|
|
239
|
+
|
|
240
|
+
return payments_n
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def get_remaining_debt_balance(debts_df, N_n, thisyear=None):
|
|
244
|
+
"""
|
|
245
|
+
Calculate total remaining debt balance at the end of the plan horizon.
|
|
246
|
+
Returns the sum of all remaining balances for loans that haven't been
|
|
247
|
+
fully paid off by the end of the plan.
|
|
248
|
+
|
|
249
|
+
Parameters:
|
|
250
|
+
-----------
|
|
251
|
+
debts_df : pd.DataFrame
|
|
252
|
+
DataFrame with columns: name, type, year, term, amount, rate
|
|
253
|
+
N_n : int
|
|
254
|
+
Number of years in the plan
|
|
255
|
+
thisyear : int, optional
|
|
256
|
+
Starting year of the plan (defaults to date.today().year)
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
--------
|
|
260
|
+
float
|
|
261
|
+
Total remaining debt balance at the end of the plan horizon.
|
|
262
|
+
Returns 0.0 if all loans are paid off or if no loans are active.
|
|
263
|
+
"""
|
|
264
|
+
if thisyear is None:
|
|
265
|
+
thisyear = date.today().year
|
|
266
|
+
|
|
267
|
+
if debts_df is None or debts_df.empty:
|
|
268
|
+
return 0.0
|
|
269
|
+
|
|
270
|
+
end_year = thisyear + N_n - 1
|
|
271
|
+
total_balance = 0.0
|
|
272
|
+
|
|
273
|
+
for _, debt in debts_df.iterrows():
|
|
274
|
+
start_year = int(debt["year"])
|
|
275
|
+
term = int(debt["term"])
|
|
276
|
+
loan_end_year = start_year + term
|
|
277
|
+
principal = float(debt["amount"])
|
|
278
|
+
rate = float(debt["rate"])
|
|
279
|
+
|
|
280
|
+
# Check if loan is still active at the end of the plan
|
|
281
|
+
if start_year <= end_year < loan_end_year:
|
|
282
|
+
# Calculate remaining balance at end of plan horizon
|
|
283
|
+
years_elapsed = end_year - start_year + 1
|
|
284
|
+
remaining_balance = calculate_remaining_balance(principal, rate, term, years_elapsed)
|
|
285
|
+
total_balance += remaining_balance
|
|
286
|
+
|
|
287
|
+
return total_balance
|