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/socialsecurity.py
CHANGED
|
@@ -18,8 +18,23 @@ import numpy as np
|
|
|
18
18
|
|
|
19
19
|
def getFRAs(yobs):
|
|
20
20
|
"""
|
|
21
|
-
Return full retirement age based on birth year.
|
|
22
|
-
|
|
21
|
+
Return full retirement age (FRA) based on birth year.
|
|
22
|
+
|
|
23
|
+
The FRA is determined by birth year according to Social Security rules:
|
|
24
|
+
- Birth year >= 1960: FRA is 67
|
|
25
|
+
- Birth year < 1960: FRA increases by 2 months for each year after 1954
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
yobs : array-like
|
|
30
|
+
Array of birth years, one for each individual.
|
|
31
|
+
|
|
32
|
+
Returns
|
|
33
|
+
-------
|
|
34
|
+
numpy.ndarray
|
|
35
|
+
Array of FRA values in fractional years (1/12 increments), one for each individual.
|
|
36
|
+
Ages are returned in Social Security age format. Comparisons to FRA should be
|
|
37
|
+
done using Social Security age (which accounts for birthday-on-first adjustments).
|
|
23
38
|
"""
|
|
24
39
|
fras = np.zeros(len(yobs))
|
|
25
40
|
|
|
@@ -35,7 +50,32 @@ def getFRAs(yobs):
|
|
|
35
50
|
|
|
36
51
|
def getSpousalBenefits(pias):
|
|
37
52
|
"""
|
|
38
|
-
Compute spousal benefit
|
|
53
|
+
Compute the maximum spousal benefit amount for each individual.
|
|
54
|
+
|
|
55
|
+
The spousal benefit is calculated as 50% of the spouse's Primary Insurance Amount (PIA),
|
|
56
|
+
minus the individual's own PIA. The result is the additional benefit the individual
|
|
57
|
+
would receive as a spouse, which cannot be negative.
|
|
58
|
+
|
|
59
|
+
Note: This calculation is not affected by which day of the month is the birthday.
|
|
60
|
+
|
|
61
|
+
Parameters
|
|
62
|
+
----------
|
|
63
|
+
pias : array-like
|
|
64
|
+
Array of Primary Insurance Amounts (monthly benefit at FRA), one for each individual.
|
|
65
|
+
Must have exactly 1 or 2 entries.
|
|
66
|
+
|
|
67
|
+
Returns
|
|
68
|
+
-------
|
|
69
|
+
numpy.ndarray
|
|
70
|
+
Array of spousal benefit amounts (monthly), one for each individual.
|
|
71
|
+
For a single individual, returns [0].
|
|
72
|
+
For two individuals, returns the additional spousal benefit each would receive
|
|
73
|
+
(which is max(0, 0.5 * spouse_PIA - own_PIA)).
|
|
74
|
+
|
|
75
|
+
Raises
|
|
76
|
+
------
|
|
77
|
+
ValueError
|
|
78
|
+
If the pias array does not have exactly 1 or 2 entries.
|
|
39
79
|
"""
|
|
40
80
|
icount = len(pias)
|
|
41
81
|
benefits = np.zeros(icount)
|
|
@@ -51,15 +91,51 @@ def getSpousalBenefits(pias):
|
|
|
51
91
|
return benefits
|
|
52
92
|
|
|
53
93
|
|
|
54
|
-
def getSelfFactor(fra,
|
|
94
|
+
def getSelfFactor(fra, convage, bornOnFirst):
|
|
55
95
|
"""
|
|
56
|
-
Return factor to multiply PIA
|
|
57
|
-
|
|
96
|
+
Return the reduction/increase factor to multiply PIA based on claiming age.
|
|
97
|
+
|
|
98
|
+
This function calculates the adjustment factor for self benefits based on when
|
|
99
|
+
Social Security benefits start relative to Full Retirement Age (FRA):
|
|
100
|
+
- Before FRA: Benefits are reduced (minimum 70% at age 62)
|
|
101
|
+
- At FRA: Full benefit (100% of PIA)
|
|
102
|
+
- After FRA: Benefits are increased by 8% per year (up to 132% at age 70)
|
|
103
|
+
|
|
104
|
+
The function automatically adjusts for Social Security age if the birthday is on
|
|
105
|
+
the first day of the month (adds 1/12 year to conventional age).
|
|
106
|
+
|
|
107
|
+
Parameters
|
|
108
|
+
----------
|
|
109
|
+
fra : float
|
|
110
|
+
Full Retirement Age in years (can be fractional with 1/12 increments).
|
|
111
|
+
convage : float
|
|
112
|
+
Conventional age when benefits start, in years (can be fractional with 1/12 increments).
|
|
113
|
+
Must be between 62 and 70 inclusive.
|
|
114
|
+
bornOnFirst : bool
|
|
115
|
+
True if birthday is on the first day of the month, False otherwise.
|
|
116
|
+
If True, the function adds 1/12 year to convert to Social Security age.
|
|
117
|
+
|
|
118
|
+
Returns
|
|
119
|
+
-------
|
|
120
|
+
float
|
|
121
|
+
Factor to multiply PIA. Examples:
|
|
122
|
+
- 0.75 = 75% of PIA (claiming at 62 with FRA of 66)
|
|
123
|
+
- 1.0 = 100% of PIA (claiming at FRA)
|
|
124
|
+
- 1.32 = 132% of PIA (claiming at 70 with FRA of 66)
|
|
125
|
+
|
|
126
|
+
Raises
|
|
127
|
+
------
|
|
128
|
+
ValueError
|
|
129
|
+
If convage is less than 62 or greater than 70.
|
|
58
130
|
"""
|
|
59
|
-
if
|
|
60
|
-
raise ValueError(f"Age {
|
|
131
|
+
if convage < 62 or convage > 70:
|
|
132
|
+
raise ValueError(f"Age {convage} out of range.")
|
|
61
133
|
|
|
62
|
-
|
|
134
|
+
# Add a month to conventional age if born on the first.
|
|
135
|
+
offset = 0 if not bornOnFirst else 1/12
|
|
136
|
+
ssage = convage + offset
|
|
137
|
+
|
|
138
|
+
diff = fra - ssage
|
|
63
139
|
if diff <= 0:
|
|
64
140
|
return 1. - .08 * diff
|
|
65
141
|
elif diff <= 3:
|
|
@@ -70,15 +146,50 @@ def getSelfFactor(fra, age):
|
|
|
70
146
|
return .8 - 0.05 * (diff - 3)
|
|
71
147
|
|
|
72
148
|
|
|
73
|
-
def getSpousalFactor(fra,
|
|
149
|
+
def getSpousalFactor(fra, convage, bornOnFirst):
|
|
74
150
|
"""
|
|
75
|
-
Return factor to multiply spousal
|
|
76
|
-
|
|
151
|
+
Return the reduction factor to multiply spousal benefits based on claiming age.
|
|
152
|
+
|
|
153
|
+
This function calculates the adjustment factor for spousal benefits based on when
|
|
154
|
+
benefits start relative to Full Retirement Age (FRA):
|
|
155
|
+
- Before FRA: Benefits are reduced (minimum 32.5% at age 62)
|
|
156
|
+
- At or after FRA: Full spousal benefit (50% of spouse's PIA, no increase for delay)
|
|
157
|
+
|
|
158
|
+
The function automatically adjusts for Social Security age if the birthday is on
|
|
159
|
+
the first day of the month (adds 1/12 year to conventional age).
|
|
160
|
+
|
|
161
|
+
Parameters
|
|
162
|
+
----------
|
|
163
|
+
fra : float
|
|
164
|
+
Full Retirement Age in years (can be fractional with 1/12 increments).
|
|
165
|
+
convage : float
|
|
166
|
+
Conventional age when benefits start, in years (can be fractional with 1/12 increments).
|
|
167
|
+
Must be at least 62 (no maximum, but no increase beyond FRA).
|
|
168
|
+
bornOnFirst : bool
|
|
169
|
+
True if birthday is on the first day of the month, False otherwise.
|
|
170
|
+
If True, the function adds 1/12 year to convert to Social Security age.
|
|
171
|
+
|
|
172
|
+
Returns
|
|
173
|
+
-------
|
|
174
|
+
float
|
|
175
|
+
Factor to multiply spousal benefit. Examples:
|
|
176
|
+
- 0.325 = 32.5% reduction factor (claiming at 62 with FRA of 66)
|
|
177
|
+
- 1.0 = 100% of spousal benefit (claiming at or after FRA)
|
|
178
|
+
Note: Unlike self benefits, spousal benefits do not increase beyond FRA.
|
|
179
|
+
|
|
180
|
+
Raises
|
|
181
|
+
------
|
|
182
|
+
ValueError
|
|
183
|
+
If convage is less than 62.
|
|
77
184
|
"""
|
|
78
|
-
if
|
|
79
|
-
raise ValueError(f"Age {
|
|
185
|
+
if convage < 62:
|
|
186
|
+
raise ValueError(f"Age {convage} out of range.")
|
|
187
|
+
|
|
188
|
+
# Add a month to conventional age if born on the first.
|
|
189
|
+
offset = 0 if not bornOnFirst else 1/12
|
|
190
|
+
ssage = convage + offset
|
|
80
191
|
|
|
81
|
-
diff = fra -
|
|
192
|
+
diff = fra - ssage
|
|
82
193
|
if diff <= 0:
|
|
83
194
|
return 1.
|
|
84
195
|
elif diff <= 3:
|
owlplanner/tax2025.py
CHANGED
|
@@ -273,6 +273,26 @@ def taxBrackets(N_i, n_d, N_n, yOBBBA=2099):
|
|
|
273
273
|
return data
|
|
274
274
|
|
|
275
275
|
|
|
276
|
+
def computeNIIT(N_i, MAGI_n, I_n, Q_n, n_d, N_n):
|
|
277
|
+
"""
|
|
278
|
+
Compute ACA tax on Dividends (Q) and Interests (I).
|
|
279
|
+
For accounting for rent and/or trust income, one can easily add a column
|
|
280
|
+
to the Wages and Contributions file and add yearly amount to Q_n + I_n below.
|
|
281
|
+
"""
|
|
282
|
+
J_n = np.zeros(N_n)
|
|
283
|
+
status = N_i - 1
|
|
284
|
+
|
|
285
|
+
for n in range(N_n):
|
|
286
|
+
if status and n == n_d:
|
|
287
|
+
status -= 1
|
|
288
|
+
|
|
289
|
+
Gmax = niitThreshold[status]
|
|
290
|
+
if MAGI_n[n] > Gmax:
|
|
291
|
+
J_n[n] = niitRate * min(MAGI_n[n] - Gmax, I_n[n] + Q_n[n])
|
|
292
|
+
|
|
293
|
+
return J_n
|
|
294
|
+
|
|
295
|
+
|
|
276
296
|
def rho_in(yobs, N_n):
|
|
277
297
|
"""
|
|
278
298
|
Return Required Minimum Distribution fractions for each individual.
|
owlplanner/tax2026.py
CHANGED
|
@@ -20,20 +20,12 @@ import numpy as np
|
|
|
20
20
|
from datetime import date
|
|
21
21
|
|
|
22
22
|
|
|
23
|
-
##############################################################################
|
|
24
|
-
# Prepare the data.
|
|
25
|
-
|
|
26
|
-
taxBracketNames = ["10%", "12/15%", "22/25%", "24/28%", "32/33%", "35%", "37/40%"]
|
|
27
|
-
|
|
28
|
-
rates_OBBBA = np.array([0.10, 0.12, 0.22, 0.24, 0.32, 0.35, 0.370])
|
|
29
|
-
rates_preTCJA = np.array([0.10, 0.15, 0.25, 0.28, 0.33, 0.35, 0.396])
|
|
30
|
-
|
|
31
23
|
###############################################################################
|
|
32
24
|
# Start of section where rates need to be actualized every year.
|
|
33
25
|
###############################################################################
|
|
34
26
|
# Single [0] and married filing jointly [1].
|
|
35
27
|
|
|
36
|
-
# These are
|
|
28
|
+
# These are current for 2026, i.e., applying to tax year 2025.
|
|
37
29
|
taxBrackets_OBBBA = np.array(
|
|
38
30
|
[
|
|
39
31
|
[12400, 50400, 105700, 201775, 256225, 640600, 9999999],
|
|
@@ -41,17 +33,20 @@ taxBrackets_OBBBA = np.array(
|
|
|
41
33
|
]
|
|
42
34
|
)
|
|
43
35
|
|
|
36
|
+
# These are current for 2026 (2025TY).
|
|
44
37
|
irmaaBrackets = np.array(
|
|
45
38
|
[
|
|
46
|
-
[0,
|
|
47
|
-
[0,
|
|
39
|
+
[0, 109000, 137000, 171000, 205000, 500000],
|
|
40
|
+
[0, 218000, 274000, 342000, 410000, 750000],
|
|
48
41
|
]
|
|
49
42
|
)
|
|
50
43
|
|
|
51
|
-
#
|
|
44
|
+
# These are current for 2026 (2025TY).
|
|
45
|
+
# Index [0] stores the standard Medicare part B basic premium.
|
|
52
46
|
# Following values are incremental IRMAA part B monthly fees.
|
|
53
|
-
irmaaFees = 12 * np.array([
|
|
47
|
+
irmaaFees = 12 * np.array([202.90, 81.20, 121.70, 121.70, 121.70, 40.70])
|
|
54
48
|
|
|
49
|
+
#########################################################################
|
|
55
50
|
# Make projection for pre-TCJA using 2017 to current year.
|
|
56
51
|
# taxBrackets_2017 = np.array(
|
|
57
52
|
# [ [9325, 37950, 91900, 191650, 416700, 418400, 9999999],
|
|
@@ -60,41 +55,60 @@ irmaaFees = 12 * np.array([185.00, 74.00, 111.00, 110.90, 111.00, 37.00])
|
|
|
60
55
|
#
|
|
61
56
|
# stdDeduction_2017 = [6350, 12700]
|
|
62
57
|
#
|
|
63
|
-
#
|
|
58
|
+
# COLA from 2017: [2.0, 2.8, 1.6, 1.3, 5.9, 8.7, 3.2, 2.5, 2.8]
|
|
59
|
+
# For 2026, I used a 35.1% adjustment from 2017, rounded to closest 10.
|
|
64
60
|
#
|
|
65
61
|
# These are speculated.
|
|
66
62
|
taxBrackets_preTCJA = np.array(
|
|
67
63
|
[
|
|
68
|
-
[
|
|
69
|
-
[
|
|
64
|
+
[12600, 51270, 124160, 258920, 562960, 565260, 9999999], # Single
|
|
65
|
+
[25200, 102540, 206840, 315260, 562960, 635920, 9999999], # MFJ
|
|
70
66
|
]
|
|
71
67
|
)
|
|
72
68
|
|
|
73
|
-
# These are
|
|
69
|
+
# These are speculated (adjusted for inflation to 2026).
|
|
70
|
+
stdDeduction_preTCJA = np.array([8580, 17160]) # Single, MFJ
|
|
71
|
+
#########################################################################
|
|
72
|
+
|
|
73
|
+
# These are current for 2026 (2025TY).
|
|
74
74
|
stdDeduction_OBBBA = np.array([16100, 32200]) # Single, MFJ
|
|
75
|
-
# These are speculated (adjusted for inflation to 2026). TODO
|
|
76
|
-
stdDeduction_preTCJA = np.array([8300, 16600]) # Single, MFJ
|
|
77
75
|
|
|
78
|
-
# These are current
|
|
79
|
-
extra65Deduction = np.array([2000, 1600])
|
|
76
|
+
# These are current for 2026 (2025TY) per individual.
|
|
77
|
+
extra65Deduction = np.array([2000, 1600]) # Single, MFJ
|
|
80
78
|
|
|
81
|
-
#
|
|
79
|
+
# These are current for 2026 (2025TY).
|
|
80
|
+
# Thresholds setting capital gains brackets 0%, 15%, 20%.
|
|
82
81
|
capGainRates = np.array(
|
|
83
82
|
[
|
|
84
|
-
[
|
|
85
|
-
[
|
|
83
|
+
[49450, 545500],
|
|
84
|
+
[98900, 613700],
|
|
86
85
|
]
|
|
87
86
|
)
|
|
88
87
|
|
|
88
|
+
###############################################################################
|
|
89
|
+
# End of section where rates need to be actualized every year.
|
|
90
|
+
###############################################################################
|
|
91
|
+
|
|
92
|
+
###############################################################################
|
|
93
|
+
# Data that is unlikely to change.
|
|
94
|
+
###############################################################################
|
|
95
|
+
|
|
89
96
|
# Thresholds for net investment income tax (not adjusted for inflation).
|
|
90
97
|
niitThreshold = np.array([200000, 250000])
|
|
91
98
|
niitRate = 0.038
|
|
92
99
|
|
|
93
|
-
# Thresholds for 65+ bonus for circumventing tax
|
|
100
|
+
# Thresholds for 65+ bonus of $6k per individual for circumventing tax
|
|
101
|
+
# on social security for low-income households. This expires in 2029.
|
|
102
|
+
# These numbers are hard-coded below as the tax code will likely change
|
|
103
|
+
# the rules for eligibility and will require a code review.
|
|
104
|
+
# Bonus decreases linearly above threshold by 1% / $1k over threshold.
|
|
94
105
|
bonusThreshold = np.array([75000, 150000])
|
|
95
106
|
|
|
96
|
-
|
|
97
|
-
|
|
107
|
+
taxBracketNames = ["10%", "12/15%", "22/25%", "24/28%", "32/33%", "35%", "37/40%"]
|
|
108
|
+
|
|
109
|
+
rates_OBBBA = np.array([0.10, 0.12, 0.22, 0.24, 0.32, 0.35, 0.370])
|
|
110
|
+
rates_preTCJA = np.array([0.10, 0.15, 0.25, 0.28, 0.33, 0.35, 0.396])
|
|
111
|
+
|
|
98
112
|
###############################################################################
|
|
99
113
|
|
|
100
114
|
|
|
@@ -273,6 +287,26 @@ def taxBrackets(N_i, n_d, N_n, yOBBBA=2099):
|
|
|
273
287
|
return data
|
|
274
288
|
|
|
275
289
|
|
|
290
|
+
def computeNIIT(N_i, MAGI_n, I_n, Q_n, n_d, N_n):
|
|
291
|
+
"""
|
|
292
|
+
Compute ACA tax on Dividends (Q) and Interests (I).
|
|
293
|
+
For accounting for rent and/or trust income, one can easily add a column
|
|
294
|
+
to the Wages and Contributions file and add yearly amount to Q_n + I_n below.
|
|
295
|
+
"""
|
|
296
|
+
J_n = np.zeros(N_n)
|
|
297
|
+
status = N_i - 1
|
|
298
|
+
|
|
299
|
+
for n in range(N_n):
|
|
300
|
+
if status and n == n_d:
|
|
301
|
+
status -= 1
|
|
302
|
+
|
|
303
|
+
Gmax = niitThreshold[status]
|
|
304
|
+
if MAGI_n[n] > Gmax:
|
|
305
|
+
J_n[n] = niitRate * min(MAGI_n[n] - Gmax, I_n[n] + Q_n[n])
|
|
306
|
+
|
|
307
|
+
return J_n
|
|
308
|
+
|
|
309
|
+
|
|
276
310
|
def rho_in(yobs, N_n):
|
|
277
311
|
"""
|
|
278
312
|
Return Required Minimum Distribution fractions for each individual.
|
owlplanner/timelists.py
CHANGED
|
@@ -34,6 +34,43 @@ _timeHorizonItems = [
|
|
|
34
34
|
]
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
_debtItems = [
|
|
38
|
+
"name",
|
|
39
|
+
"type",
|
|
40
|
+
"year",
|
|
41
|
+
"term",
|
|
42
|
+
"amount",
|
|
43
|
+
"rate",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
_debtTypes = [
|
|
48
|
+
"loan",
|
|
49
|
+
"mortgage",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_fixedAssetItems = [
|
|
54
|
+
"name",
|
|
55
|
+
"type",
|
|
56
|
+
"basis",
|
|
57
|
+
"value",
|
|
58
|
+
"rate",
|
|
59
|
+
"yod",
|
|
60
|
+
"commission",
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
_fixedAssetTypes = [
|
|
65
|
+
"collectibles",
|
|
66
|
+
"fixed annuity",
|
|
67
|
+
"precious metals",
|
|
68
|
+
"real estate",
|
|
69
|
+
"residence",
|
|
70
|
+
"stocks",
|
|
71
|
+
]
|
|
72
|
+
|
|
73
|
+
|
|
37
74
|
def read(finput, inames, horizons, mylog):
|
|
38
75
|
"""
|
|
39
76
|
Read listed parameters from an excel spreadsheet or through
|
|
@@ -54,19 +91,40 @@ def read(finput, inames, horizons, mylog):
|
|
|
54
91
|
else:
|
|
55
92
|
# Read all worksheets in memory but only process those with proper names.
|
|
56
93
|
try:
|
|
57
|
-
dfDict = pd.read_excel(finput, sheet_name=None, usecols=_timeHorizonItems)
|
|
94
|
+
# dfDict = pd.read_excel(finput, sheet_name=None, usecols=_timeHorizonItems)
|
|
95
|
+
dfDict = pd.read_excel(finput, sheet_name=None)
|
|
58
96
|
except Exception as e:
|
|
59
97
|
raise Exception(f"Could not read file {finput}: {e}.") from e
|
|
60
98
|
streamName = f"file '{finput}'"
|
|
61
99
|
|
|
62
|
-
timeLists =
|
|
63
|
-
|
|
100
|
+
timeLists = _conditionTimetables(dfDict, inames, horizons, mylog)
|
|
64
101
|
mylog.vprint(f"Successfully read time horizons from {streamName}.")
|
|
65
102
|
|
|
66
|
-
|
|
103
|
+
houseLists = _conditionHouseTables(dfDict, mylog)
|
|
104
|
+
mylog.vprint(f"Successfully read household tables from {streamName}.")
|
|
105
|
+
|
|
106
|
+
return finput, timeLists, houseLists
|
|
67
107
|
|
|
68
108
|
|
|
69
|
-
def
|
|
109
|
+
def _checkColumns(df, iname, colList):
|
|
110
|
+
"""
|
|
111
|
+
Ensure all columns in colList are present. Remove others.
|
|
112
|
+
"""
|
|
113
|
+
# Drop all columns not in the list.
|
|
114
|
+
df = df.loc[:, ~df.columns.str.contains("^Unnamed")]
|
|
115
|
+
for col in df.columns:
|
|
116
|
+
if col == "" or col not in colList:
|
|
117
|
+
df.drop(col, axis=1, inplace=True)
|
|
118
|
+
|
|
119
|
+
# Check that all columns in the list are present.
|
|
120
|
+
for item in colList:
|
|
121
|
+
if item not in df.columns:
|
|
122
|
+
raise ValueError(f"Column {item} not found for {iname}.")
|
|
123
|
+
|
|
124
|
+
return df
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _conditionTimetables(dfDict, inames, horizons, mylog):
|
|
70
128
|
"""
|
|
71
129
|
Make sure that time horizons contain all years up to life expectancy,
|
|
72
130
|
and that values are positive (except big-ticket items).
|
|
@@ -81,14 +139,7 @@ def _condition(dfDict, inames, horizons, mylog):
|
|
|
81
139
|
|
|
82
140
|
df = dfDict[iname]
|
|
83
141
|
|
|
84
|
-
df = df
|
|
85
|
-
for col in df.columns:
|
|
86
|
-
if col == "" or col not in _timeHorizonItems:
|
|
87
|
-
df.drop(col, axis=1, inplace=True)
|
|
88
|
-
|
|
89
|
-
for item in _timeHorizonItems:
|
|
90
|
-
if item not in df.columns:
|
|
91
|
-
raise ValueError(f"Item {item} not found for {iname}.")
|
|
142
|
+
df = _checkColumns(df, iname, _timeHorizonItems)
|
|
92
143
|
|
|
93
144
|
# Only consider lines in proper year range. Go back 5 years for Roth maturation.
|
|
94
145
|
df = df[df["year"] >= (thisyear - 5)]
|
|
@@ -97,13 +148,14 @@ def _condition(dfDict, inames, horizons, mylog):
|
|
|
97
148
|
missing = []
|
|
98
149
|
for n in range(-5, horizons[i]):
|
|
99
150
|
year = thisyear + n
|
|
100
|
-
|
|
151
|
+
year_rows = df[df["year"] == year]
|
|
152
|
+
if year_rows.empty:
|
|
101
153
|
df.loc[len(df)] = [year, 0, 0, 0, 0, 0, 0, 0, 0]
|
|
102
154
|
missing.append(year)
|
|
103
155
|
else:
|
|
104
156
|
for item in _timeHorizonItems:
|
|
105
|
-
if item != "big-ticket items" and
|
|
106
|
-
raise ValueError(f"Item {item} for {iname} in year {
|
|
157
|
+
if item != "big-ticket items" and year_rows[item].iloc[0] < 0:
|
|
158
|
+
raise ValueError(f"Item {item} for {iname} in year {year} is < 0.")
|
|
107
159
|
|
|
108
160
|
if len(missing) > 0:
|
|
109
161
|
mylog.vprint(f"Adding {len(missing)} missing years for {iname}: {missing}.")
|
|
@@ -115,8 +167,64 @@ def _condition(dfDict, inames, horizons, mylog):
|
|
|
115
167
|
timeLists[iname] = df
|
|
116
168
|
|
|
117
169
|
if df["year"].iloc[-1] != endyear - 1:
|
|
118
|
-
raise ValueError(
|
|
119
|
-
|
|
120
|
-
)
|
|
170
|
+
raise ValueError(f"""Time horizon for {iname} too short.\n\t
|
|
171
|
+
It should end in {endyear}, not {df['year'].iloc[-1]}""")
|
|
121
172
|
|
|
122
173
|
return timeLists
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _conditionHouseTables(dfDict, mylog):
|
|
177
|
+
"""
|
|
178
|
+
Read debts and fixed assets from Household Financial Profile workbook.
|
|
179
|
+
"""
|
|
180
|
+
houseDic = {}
|
|
181
|
+
|
|
182
|
+
items = {"Debts" : _debtItems, "Fixed Assets": _fixedAssetItems}
|
|
183
|
+
types = {"Debts" : _debtTypes, "Fixed Assets": _fixedAssetTypes}
|
|
184
|
+
for page in items.keys():
|
|
185
|
+
if page in dfDict:
|
|
186
|
+
df = dfDict[page]
|
|
187
|
+
df = _checkColumns(df, page, items[page])
|
|
188
|
+
# Check categorical variables.
|
|
189
|
+
isInList = df["type"].isin(types[page])
|
|
190
|
+
df = df[isInList]
|
|
191
|
+
|
|
192
|
+
# Convert percentage columns from decimal to percentage if needed
|
|
193
|
+
# UI uses 0-100 range for percentages (e.g., 4.5 = 4.5%)
|
|
194
|
+
# If Excel read percentage-formatted cells, values might be decimals (0.045)
|
|
195
|
+
# Convert values < 1.0 to percentage format (multiply by 100)
|
|
196
|
+
if page == "Debts" and "rate" in df.columns:
|
|
197
|
+
# If rate values are less than 1, assume they're decimals (0.045 = 4.5%)
|
|
198
|
+
# and convert to percentages (4.5) to match UI format (0-100 range)
|
|
199
|
+
mask = (df["rate"] < 1.0) & (df["rate"] > 0)
|
|
200
|
+
if mask.any():
|
|
201
|
+
df.loc[mask, "rate"] = df.loc[mask, "rate"] * 100.0
|
|
202
|
+
mylog.vprint(f"Converted {mask.sum()} rate value(s) from decimal to percentage in Debts table.")
|
|
203
|
+
|
|
204
|
+
elif page == "Fixed Assets":
|
|
205
|
+
# Convert rate and commission if they're decimals
|
|
206
|
+
# Both should be in 0-100 range to match UI format
|
|
207
|
+
if "rate" in df.columns:
|
|
208
|
+
mask = (df["rate"] < 1.0) & (df["rate"] > 0)
|
|
209
|
+
if mask.any():
|
|
210
|
+
df.loc[mask, "rate"] = df.loc[mask, "rate"] * 100.0
|
|
211
|
+
mylog.vprint(
|
|
212
|
+
f"Converted {mask.sum()} rate value(s) from decimal "
|
|
213
|
+
f"to percentage in Fixed Assets table."
|
|
214
|
+
)
|
|
215
|
+
if "commission" in df.columns:
|
|
216
|
+
mask = (df["commission"] < 1.0) & (df["commission"] > 0)
|
|
217
|
+
if mask.any():
|
|
218
|
+
df.loc[mask, "commission"] = df.loc[mask, "commission"] * 100.0
|
|
219
|
+
mylog.vprint(
|
|
220
|
+
f"Converted {mask.sum()} commission value(s) from decimal "
|
|
221
|
+
f"to percentage in Fixed Assets table."
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
houseDic[page] = df
|
|
225
|
+
mylog.vprint(f"Found {len(df)} valid row(s) in {page} table.")
|
|
226
|
+
else:
|
|
227
|
+
houseDic[page] = pd.DataFrame(columns=items[page])
|
|
228
|
+
mylog.vprint(f"Table for {page} not found. Assuming empty table.")
|
|
229
|
+
|
|
230
|
+
return houseDic
|
owlplanner/utils.py
CHANGED
|
@@ -70,7 +70,7 @@ def getUnits(units) -> int:
|
|
|
70
70
|
return fac
|
|
71
71
|
|
|
72
72
|
|
|
73
|
-
# Next two
|
|
73
|
+
# Next two functions could be a one-line lambda functions.
|
|
74
74
|
# e.g., krond = lambda a, b: 1 if a == b else 0
|
|
75
75
|
def krond(a, b) -> int:
|
|
76
76
|
"""
|
|
@@ -101,3 +101,27 @@ def roundCents(values, decimals=2):
|
|
|
101
101
|
arr = np.where((-0.009 < arr) & (arr <= 0), 0, arr)
|
|
102
102
|
|
|
103
103
|
return arr
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def parseDobs(dobs):
|
|
107
|
+
"""
|
|
108
|
+
Parse a list of dates and return int32 arrays of year, months, days.
|
|
109
|
+
"""
|
|
110
|
+
icount = len(dobs)
|
|
111
|
+
yobs = []
|
|
112
|
+
mobs = []
|
|
113
|
+
tobs = []
|
|
114
|
+
for i in range(icount):
|
|
115
|
+
ls = dobs[i].split("-")
|
|
116
|
+
if len(ls) != 3:
|
|
117
|
+
raise ValueError(f"Date {dobs[i]} not in ISO format.")
|
|
118
|
+
if not 1 <= int(ls[1]) <= 12:
|
|
119
|
+
raise ValueError(f"Month in date {dobs[i]} not valid.")
|
|
120
|
+
if not 1 <= int(ls[2]) <= 31:
|
|
121
|
+
raise ValueError(f"Day in date {dobs[i]} not valid.")
|
|
122
|
+
|
|
123
|
+
yobs.append(ls[0])
|
|
124
|
+
mobs.append(ls[1])
|
|
125
|
+
tobs.append(ls[2])
|
|
126
|
+
|
|
127
|
+
return np.array(yobs, dtype=np.int32), np.array(mobs, dtype=np.int32), np.array(tobs, dtype=np.int32)
|
owlplanner/version.py
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
__version__ = "2025.12.
|
|
1
|
+
__version__ = "2025.12.20"
|