owlplanner 2025.12.5__py3-none-any.whl → 2026.1.26__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/In Discussion #58, the case of Kim and Sam.md +307 -0
- owlplanner/__init__.py +20 -1
- owlplanner/abcapi.py +24 -23
- owlplanner/cli/README.md +50 -0
- owlplanner/cli/_main.py +52 -0
- owlplanner/cli/cli_logging.py +56 -0
- owlplanner/cli/cmd_list.py +83 -0
- owlplanner/cli/cmd_run.py +86 -0
- owlplanner/config.py +315 -136
- owlplanner/data/__init__.py +21 -0
- owlplanner/data/awi.csv +75 -0
- owlplanner/data/bendpoints.csv +49 -0
- owlplanner/data/newawi.csv +75 -0
- owlplanner/data/rates.csv +99 -98
- owlplanner/debts.py +315 -0
- owlplanner/fixedassets.py +288 -0
- owlplanner/mylogging.py +157 -25
- owlplanner/plan.py +1044 -332
- owlplanner/plotting/__init__.py +16 -3
- owlplanner/plotting/base.py +17 -3
- owlplanner/plotting/factory.py +16 -3
- owlplanner/plotting/matplotlib_backend.py +30 -7
- owlplanner/plotting/plotly_backend.py +33 -10
- owlplanner/progress.py +66 -9
- owlplanner/rates.py +366 -361
- owlplanner/socialsecurity.py +142 -22
- owlplanner/tax2026.py +170 -57
- owlplanner/timelists.py +316 -32
- owlplanner/utils.py +204 -5
- owlplanner/version.py +20 -1
- {owlplanner-2025.12.5.dist-info → owlplanner-2026.1.26.dist-info}/METADATA +50 -158
- owlplanner-2026.1.26.dist-info/RECORD +36 -0
- owlplanner-2026.1.26.dist-info/entry_points.txt +2 -0
- owlplanner-2026.1.26.dist-info/licenses/AUTHORS +15 -0
- owlplanner/tax2025.py +0 -339
- owlplanner-2025.12.5.dist-info/RECORD +0 -24
- {owlplanner-2025.12.5.dist-info → owlplanner-2026.1.26.dist-info}/WHEEL +0 -0
- {owlplanner-2025.12.5.dist-info → owlplanner-2026.1.26.dist-info}/licenses/LICENSE +0 -0
owlplanner/socialsecurity.py
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
"""
|
|
2
|
+
Social Security benefit calculation rules and utilities.
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
4
|
+
This module implements Social Security rules including full retirement age
|
|
5
|
+
calculations, benefit computations, and related retirement planning functions.
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
Copyright (C) 2025-2026 The Owlplanner Authors
|
|
7
8
|
|
|
8
|
-
This
|
|
9
|
+
This program is free software: you can redistribute it and/or modify
|
|
10
|
+
it under the terms of the GNU General Public License as published by
|
|
11
|
+
the Free Software Foundation, either version 3 of the License, or
|
|
12
|
+
(at your option) any later version.
|
|
9
13
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
14
|
+
This program is distributed in the hope that it will be useful,
|
|
15
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
16
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
17
|
+
GNU General Public License for more details.
|
|
13
18
|
|
|
19
|
+
You should have received a copy of the GNU General Public License
|
|
20
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
14
21
|
"""
|
|
15
22
|
|
|
16
23
|
import numpy as np
|
|
@@ -18,8 +25,23 @@ import numpy as np
|
|
|
18
25
|
|
|
19
26
|
def getFRAs(yobs):
|
|
20
27
|
"""
|
|
21
|
-
Return full retirement age based on birth year.
|
|
22
|
-
|
|
28
|
+
Return full retirement age (FRA) based on birth year.
|
|
29
|
+
|
|
30
|
+
The FRA is determined by birth year according to Social Security rules:
|
|
31
|
+
- Birth year >= 1960: FRA is 67
|
|
32
|
+
- Birth year < 1960: FRA increases by 2 months for each year after 1954
|
|
33
|
+
|
|
34
|
+
Parameters
|
|
35
|
+
----------
|
|
36
|
+
yobs : array-like
|
|
37
|
+
Array of birth years, one for each individual.
|
|
38
|
+
|
|
39
|
+
Returns
|
|
40
|
+
-------
|
|
41
|
+
numpy.ndarray
|
|
42
|
+
Array of FRA values in fractional years (1/12 increments), one for each individual.
|
|
43
|
+
Ages are returned in Social Security age format. Comparisons to FRA should be
|
|
44
|
+
done using Social Security age (which accounts for birthday-on-first adjustments).
|
|
23
45
|
"""
|
|
24
46
|
fras = np.zeros(len(yobs))
|
|
25
47
|
|
|
@@ -35,7 +57,32 @@ def getFRAs(yobs):
|
|
|
35
57
|
|
|
36
58
|
def getSpousalBenefits(pias):
|
|
37
59
|
"""
|
|
38
|
-
Compute spousal benefit
|
|
60
|
+
Compute the maximum spousal benefit amount for each individual.
|
|
61
|
+
|
|
62
|
+
The spousal benefit is calculated as 50% of the spouse's Primary Insurance Amount (PIA),
|
|
63
|
+
minus the individual's own PIA. The result is the additional benefit the individual
|
|
64
|
+
would receive as a spouse, which cannot be negative.
|
|
65
|
+
|
|
66
|
+
Note: This calculation is not affected by which day of the month is the birthday.
|
|
67
|
+
|
|
68
|
+
Parameters
|
|
69
|
+
----------
|
|
70
|
+
pias : array-like
|
|
71
|
+
Array of Primary Insurance Amounts (monthly benefit at FRA), one for each individual.
|
|
72
|
+
Must have exactly 1 or 2 entries.
|
|
73
|
+
|
|
74
|
+
Returns
|
|
75
|
+
-------
|
|
76
|
+
numpy.ndarray
|
|
77
|
+
Array of spousal benefit amounts (monthly), one for each individual.
|
|
78
|
+
For a single individual, returns [0].
|
|
79
|
+
For two individuals, returns the additional spousal benefit each would receive
|
|
80
|
+
(which is max(0, 0.5 * spouse_PIA - own_PIA)).
|
|
81
|
+
|
|
82
|
+
Raises
|
|
83
|
+
------
|
|
84
|
+
ValueError
|
|
85
|
+
If the pias array does not have exactly 1 or 2 entries.
|
|
39
86
|
"""
|
|
40
87
|
icount = len(pias)
|
|
41
88
|
benefits = np.zeros(icount)
|
|
@@ -51,15 +98,52 @@ def getSpousalBenefits(pias):
|
|
|
51
98
|
return benefits
|
|
52
99
|
|
|
53
100
|
|
|
54
|
-
def getSelfFactor(fra,
|
|
101
|
+
def getSelfFactor(fra, convage, bornOnFirstDays):
|
|
55
102
|
"""
|
|
56
|
-
Return factor to multiply PIA
|
|
57
|
-
|
|
103
|
+
Return the reduction/increase factor to multiply PIA based on claiming age.
|
|
104
|
+
|
|
105
|
+
This function calculates the adjustment factor for self benefits based on when
|
|
106
|
+
Social Security benefits start relative to Full Retirement Age (FRA):
|
|
107
|
+
- Before FRA: Benefits are reduced (minimum 70% at age 62)
|
|
108
|
+
- At FRA: Full benefit (100% of PIA)
|
|
109
|
+
- After FRA: Benefits are increased by 8% per year (up to 132% at age 70)
|
|
110
|
+
|
|
111
|
+
The function automatically adjusts for Social Security age if the birthday is on
|
|
112
|
+
the 1st or 2nd day of the month (adds 1/12 year to conventional age), consistent
|
|
113
|
+
with SSA rules that treat both days the same for age calculation purposes.
|
|
114
|
+
|
|
115
|
+
Parameters
|
|
116
|
+
----------
|
|
117
|
+
fra : float
|
|
118
|
+
Full Retirement Age in years (can be fractional with 1/12 increments).
|
|
119
|
+
convage : float
|
|
120
|
+
Conventional age when benefits start, in years (can be fractional with 1/12 increments).
|
|
121
|
+
Must be between 62 and 70 inclusive.
|
|
122
|
+
bornOnFirstDays : bool
|
|
123
|
+
True if birthday is on the 1st or 2nd day of the month, False otherwise.
|
|
124
|
+
If True, the function adds 1/12 year to convert to Social Security age.
|
|
125
|
+
|
|
126
|
+
Returns
|
|
127
|
+
-------
|
|
128
|
+
float
|
|
129
|
+
Factor to multiply PIA. Examples:
|
|
130
|
+
- 0.75 = 75% of PIA (claiming at 62 with FRA of 66)
|
|
131
|
+
- 1.0 = 100% of PIA (claiming at FRA)
|
|
132
|
+
- 1.32 = 132% of PIA (claiming at 70 with FRA of 66)
|
|
133
|
+
|
|
134
|
+
Raises
|
|
135
|
+
------
|
|
136
|
+
ValueError
|
|
137
|
+
If convage is less than 62 or greater than 70.
|
|
58
138
|
"""
|
|
59
|
-
if
|
|
60
|
-
raise ValueError(f"Age {
|
|
139
|
+
if convage < 62 or convage > 70:
|
|
140
|
+
raise ValueError(f"Age {convage} out of range.")
|
|
141
|
+
|
|
142
|
+
# Add a month to conventional age if born on the 1st or 2nd (SSA treats both the same).
|
|
143
|
+
offset = 0 if not bornOnFirstDays else 1/12
|
|
144
|
+
ssage = convage + offset
|
|
61
145
|
|
|
62
|
-
diff = fra -
|
|
146
|
+
diff = fra - ssage
|
|
63
147
|
if diff <= 0:
|
|
64
148
|
return 1. - .08 * diff
|
|
65
149
|
elif diff <= 3:
|
|
@@ -70,15 +154,51 @@ def getSelfFactor(fra, age):
|
|
|
70
154
|
return .8 - 0.05 * (diff - 3)
|
|
71
155
|
|
|
72
156
|
|
|
73
|
-
def getSpousalFactor(fra,
|
|
157
|
+
def getSpousalFactor(fra, convage, bornOnFirstDays):
|
|
74
158
|
"""
|
|
75
|
-
Return factor to multiply spousal
|
|
76
|
-
|
|
159
|
+
Return the reduction factor to multiply spousal benefits based on claiming age.
|
|
160
|
+
|
|
161
|
+
This function calculates the adjustment factor for spousal benefits based on when
|
|
162
|
+
benefits start relative to Full Retirement Age (FRA):
|
|
163
|
+
- Before FRA: Benefits are reduced (minimum 32.5% at age 62)
|
|
164
|
+
- At or after FRA: Full spousal benefit (50% of spouse's PIA, no increase for delay)
|
|
165
|
+
|
|
166
|
+
The function automatically adjusts for Social Security age if the birthday is on
|
|
167
|
+
the 1st or 2nd day of the month (adds 1/12 year to conventional age), consistent
|
|
168
|
+
with SSA rules that treat both days the same for age calculation purposes.
|
|
169
|
+
|
|
170
|
+
Parameters
|
|
171
|
+
----------
|
|
172
|
+
fra : float
|
|
173
|
+
Full Retirement Age in years (can be fractional with 1/12 increments).
|
|
174
|
+
convage : float
|
|
175
|
+
Conventional age when benefits start, in years (can be fractional with 1/12 increments).
|
|
176
|
+
Must be at least 62 (no maximum, but no increase beyond FRA).
|
|
177
|
+
bornOnFirstDays : bool
|
|
178
|
+
True if birthday is on the 1st or 2nd day of the month, False otherwise.
|
|
179
|
+
If True, the function adds 1/12 year to convert to Social Security age.
|
|
180
|
+
|
|
181
|
+
Returns
|
|
182
|
+
-------
|
|
183
|
+
float
|
|
184
|
+
Factor to multiply spousal benefit. Examples:
|
|
185
|
+
- 0.325 = 32.5% reduction factor (claiming at 62 with FRA of 66)
|
|
186
|
+
- 1.0 = 100% of spousal benefit (claiming at or after FRA)
|
|
187
|
+
Note: Unlike self benefits, spousal benefits do not increase beyond FRA.
|
|
188
|
+
|
|
189
|
+
Raises
|
|
190
|
+
------
|
|
191
|
+
ValueError
|
|
192
|
+
If convage is less than 62.
|
|
77
193
|
"""
|
|
78
|
-
if
|
|
79
|
-
raise ValueError(f"Age {
|
|
194
|
+
if convage < 62:
|
|
195
|
+
raise ValueError(f"Age {convage} out of range.")
|
|
196
|
+
|
|
197
|
+
# Add a month to conventional age if born on the 1st or 2nd (SSA treats both the same).
|
|
198
|
+
offset = 0 if not bornOnFirstDays else 1/12
|
|
199
|
+
ssage = convage + offset
|
|
80
200
|
|
|
81
|
-
diff = fra -
|
|
201
|
+
diff = fra - ssage
|
|
82
202
|
if diff <= 0:
|
|
83
203
|
return 1.
|
|
84
204
|
elif diff <= 3:
|
owlplanner/tax2026.py
CHANGED
|
@@ -1,39 +1,35 @@
|
|
|
1
1
|
"""
|
|
2
|
+
Tax calculation module for 2026 tax year rules.
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
4
|
+
This module handles all tax calculations including income tax brackets,
|
|
5
|
+
capital gains tax, and other tax-related computations based on 2026 tax rules.
|
|
5
6
|
|
|
6
|
-
|
|
7
|
+
Copyright (C) 2025-2026 The Owlplanner Authors
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
of
|
|
9
|
+
This program is free software: you can redistribute it and/or modify
|
|
10
|
+
it under the terms of the GNU General Public License as published by
|
|
11
|
+
the Free Software Foundation, either version 3 of the License, or
|
|
12
|
+
(at your option) any later version.
|
|
10
13
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
Disclaimers: This code is for educational purposes only and does not constitute financial advice.
|
|
14
|
+
This program is distributed in the hope that it will be useful,
|
|
15
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
16
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
17
|
+
GNU General Public License for more details.
|
|
16
18
|
|
|
19
|
+
You should have received a copy of the GNU General Public License
|
|
20
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
17
21
|
"""
|
|
18
22
|
|
|
19
23
|
import numpy as np
|
|
20
24
|
from datetime import date
|
|
21
25
|
|
|
22
26
|
|
|
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
27
|
###############################################################################
|
|
32
28
|
# Start of section where rates need to be actualized every year.
|
|
33
29
|
###############################################################################
|
|
34
30
|
# Single [0] and married filing jointly [1].
|
|
35
31
|
|
|
36
|
-
# These are
|
|
32
|
+
# These are current for 2026, i.e., applying to tax year 2025.
|
|
37
33
|
taxBrackets_OBBBA = np.array(
|
|
38
34
|
[
|
|
39
35
|
[12400, 50400, 105700, 201775, 256225, 640600, 9999999],
|
|
@@ -41,17 +37,20 @@ taxBrackets_OBBBA = np.array(
|
|
|
41
37
|
]
|
|
42
38
|
)
|
|
43
39
|
|
|
40
|
+
# These are current for 2026 (2025TY).
|
|
44
41
|
irmaaBrackets = np.array(
|
|
45
42
|
[
|
|
46
|
-
[0,
|
|
47
|
-
[0,
|
|
43
|
+
[0, 109000, 137000, 171000, 205000, 500000],
|
|
44
|
+
[0, 218000, 274000, 342000, 410000, 750000],
|
|
48
45
|
]
|
|
49
46
|
)
|
|
50
47
|
|
|
51
|
-
#
|
|
48
|
+
# These are current for 2026 (2025TY).
|
|
49
|
+
# Index [0] stores the standard Medicare part B basic premium.
|
|
52
50
|
# Following values are incremental IRMAA part B monthly fees.
|
|
53
|
-
irmaaFees = 12 * np.array([
|
|
51
|
+
irmaaFees = 12 * np.array([202.90, 81.20, 121.70, 121.70, 121.70, 40.70])
|
|
54
52
|
|
|
53
|
+
#########################################################################
|
|
55
54
|
# Make projection for pre-TCJA using 2017 to current year.
|
|
56
55
|
# taxBrackets_2017 = np.array(
|
|
57
56
|
# [ [9325, 37950, 91900, 191650, 416700, 418400, 9999999],
|
|
@@ -60,41 +59,60 @@ irmaaFees = 12 * np.array([185.00, 74.00, 111.00, 110.90, 111.00, 37.00])
|
|
|
60
59
|
#
|
|
61
60
|
# stdDeduction_2017 = [6350, 12700]
|
|
62
61
|
#
|
|
63
|
-
#
|
|
62
|
+
# COLA from 2017: [2.0, 2.8, 1.6, 1.3, 5.9, 8.7, 3.2, 2.5, 2.8]
|
|
63
|
+
# For 2026, I used a 35.1% adjustment from 2017, rounded to closest 10.
|
|
64
64
|
#
|
|
65
65
|
# These are speculated.
|
|
66
66
|
taxBrackets_preTCJA = np.array(
|
|
67
67
|
[
|
|
68
|
-
[
|
|
69
|
-
[
|
|
68
|
+
[12600, 51270, 124160, 258920, 562960, 565260, 9999999], # Single
|
|
69
|
+
[25200, 102540, 206840, 315260, 562960, 635920, 9999999], # MFJ
|
|
70
70
|
]
|
|
71
71
|
)
|
|
72
72
|
|
|
73
|
-
# These are
|
|
73
|
+
# These are speculated (adjusted for inflation to 2026).
|
|
74
|
+
stdDeduction_preTCJA = np.array([8580, 17160]) # Single, MFJ
|
|
75
|
+
#########################################################################
|
|
76
|
+
|
|
77
|
+
# These are current for 2026 (2025TY).
|
|
74
78
|
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
79
|
|
|
78
|
-
# These are current
|
|
79
|
-
extra65Deduction = np.array([2000, 1600])
|
|
80
|
+
# These are current for 2026 (2025TY) per individual.
|
|
81
|
+
extra65Deduction = np.array([2000, 1600]) # Single, MFJ
|
|
80
82
|
|
|
81
|
-
#
|
|
83
|
+
# These are current for 2026 (2025TY).
|
|
84
|
+
# Thresholds setting capital gains brackets 0%, 15%, 20%.
|
|
82
85
|
capGainRates = np.array(
|
|
83
86
|
[
|
|
84
|
-
[
|
|
85
|
-
[
|
|
87
|
+
[49450, 545500],
|
|
88
|
+
[98900, 613700],
|
|
86
89
|
]
|
|
87
90
|
)
|
|
88
91
|
|
|
92
|
+
###############################################################################
|
|
93
|
+
# End of section where rates need to be actualized every year.
|
|
94
|
+
###############################################################################
|
|
95
|
+
|
|
96
|
+
###############################################################################
|
|
97
|
+
# Data that is unlikely to change.
|
|
98
|
+
###############################################################################
|
|
99
|
+
|
|
89
100
|
# Thresholds for net investment income tax (not adjusted for inflation).
|
|
90
101
|
niitThreshold = np.array([200000, 250000])
|
|
91
102
|
niitRate = 0.038
|
|
92
103
|
|
|
93
|
-
# Thresholds for 65+ bonus for circumventing tax
|
|
104
|
+
# Thresholds for 65+ bonus of $6k per individual for circumventing tax
|
|
105
|
+
# on social security for low-income households. This expires in 2029.
|
|
106
|
+
# These numbers are hard-coded below as the tax code will likely change
|
|
107
|
+
# the rules for eligibility and will require a code review.
|
|
108
|
+
# Bonus decreases linearly above threshold by 1% / $1k over threshold.
|
|
94
109
|
bonusThreshold = np.array([75000, 150000])
|
|
95
110
|
|
|
96
|
-
|
|
97
|
-
|
|
111
|
+
taxBracketNames = ["10%", "12/15%", "22/25%", "24/28%", "32/33%", "35%", "37/40%"]
|
|
112
|
+
|
|
113
|
+
rates_OBBBA = np.array([0.10, 0.12, 0.22, 0.24, 0.32, 0.35, 0.370])
|
|
114
|
+
rates_preTCJA = np.array([0.10, 0.15, 0.25, 0.28, 0.33, 0.35, 0.396])
|
|
115
|
+
|
|
98
116
|
###############################################################################
|
|
99
117
|
|
|
100
118
|
|
|
@@ -102,25 +120,26 @@ def mediVals(yobs, horizons, gamma_n, Nn, Nq):
|
|
|
102
120
|
"""
|
|
103
121
|
Return tuple (nm, L, C) of year index when Medicare starts and vectors L, and C
|
|
104
122
|
defining end points of constant piecewise linear functions representing IRMAA fees.
|
|
123
|
+
Costs C include the fact that one or two indivuals have to pay. Eligibility is built-in.
|
|
105
124
|
"""
|
|
106
125
|
thisyear = date.today().year
|
|
107
126
|
assert Nq == len(irmaaFees), f"Inconsistent value of Nq: {Nq}."
|
|
108
127
|
assert Nq == len(irmaaBrackets[0]), "Inconsistent IRMAA brackets array."
|
|
109
128
|
Ni = len(yobs)
|
|
110
|
-
# What index year will Medicare start? 65 - age.
|
|
111
|
-
nm = 65 -
|
|
112
|
-
nm = np.
|
|
129
|
+
# What index year will Medicare start? 65 - age for each individual.
|
|
130
|
+
nm = yobs + 65 - thisyear
|
|
131
|
+
nm = np.maximum(0, nm)
|
|
132
|
+
nmstart = np.min(nm)
|
|
113
133
|
# Has it already started?
|
|
114
|
-
|
|
115
|
-
Nmed = Nn - nm
|
|
134
|
+
Nmed = Nn - nmstart
|
|
116
135
|
|
|
117
136
|
L = np.zeros((Nmed, Nq-1))
|
|
118
137
|
C = np.zeros((Nmed, Nq))
|
|
119
138
|
|
|
120
|
-
# Year starts at offset
|
|
139
|
+
# Year starts at offset nmstart in the plan. L and C arrays are shorter.
|
|
121
140
|
for nn in range(Nmed):
|
|
122
141
|
imed = 0
|
|
123
|
-
n =
|
|
142
|
+
n = nmstart + nn
|
|
124
143
|
if thisyear + n - yobs[0] >= 65 and n < horizons[0]:
|
|
125
144
|
imed += 1
|
|
126
145
|
if Ni == 2 and thisyear + n - yobs[1] >= 65 and n < horizons[1]:
|
|
@@ -132,28 +151,83 @@ def mediVals(yobs, horizons, gamma_n, Nn, Nq):
|
|
|
132
151
|
else:
|
|
133
152
|
raise RuntimeError("mediVals: This should never happen.")
|
|
134
153
|
|
|
135
|
-
return
|
|
154
|
+
return nmstart, L, C
|
|
136
155
|
|
|
137
156
|
|
|
138
|
-
def
|
|
157
|
+
def capitalGainTax(Ni, txIncome_n, ltcg_n, gamma_n, nd, Nn):
|
|
139
158
|
"""
|
|
140
|
-
Return an array of
|
|
141
|
-
|
|
142
|
-
|
|
159
|
+
Return an array of tax on capital gains.
|
|
160
|
+
|
|
161
|
+
Parameters:
|
|
162
|
+
-----------
|
|
163
|
+
Ni : int
|
|
164
|
+
Number of individuals (1 or 2)
|
|
165
|
+
txIncome_n : array
|
|
166
|
+
Array of taxable income for each year (ordinary income + capital gains)
|
|
167
|
+
ltcg_n : array
|
|
168
|
+
Array of long-term capital gains for each year
|
|
169
|
+
gamma_n : array
|
|
170
|
+
Array of inflation adjustment factors for each year
|
|
171
|
+
nd : int
|
|
172
|
+
Index year of first passing of a spouse, if applicable (nd == Nn for single individuals)
|
|
173
|
+
Nn : int
|
|
174
|
+
Total number of years in the plan
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
--------
|
|
178
|
+
cgTax_n : array
|
|
179
|
+
Array of tax on capital gains for each year
|
|
180
|
+
|
|
181
|
+
Notes:
|
|
182
|
+
------
|
|
183
|
+
Thresholds are determined by the taxable income which is roughly AGI - (standard/itemized) deductions.
|
|
184
|
+
Taxable income can also be thought of as taxable ordinary income + capital gains.
|
|
185
|
+
|
|
186
|
+
Long-term capital gains are taxed at 0%, 15%, or 20% based on total taxable income.
|
|
187
|
+
Capital gains "stack on top" of ordinary income, so the portion of gains that
|
|
188
|
+
pushes total income above each threshold is taxed at the corresponding rate.
|
|
143
189
|
"""
|
|
144
190
|
status = Ni - 1
|
|
145
|
-
|
|
191
|
+
cgTax_n = np.zeros(Nn)
|
|
146
192
|
|
|
147
193
|
for n in range(Nn):
|
|
148
194
|
if status and n == nd:
|
|
149
195
|
status -= 1
|
|
150
196
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
197
|
+
# Calculate ordinary income (taxable income minus capital gains).
|
|
198
|
+
ordIncome = txIncome_n[n] - ltcg_n[n]
|
|
199
|
+
|
|
200
|
+
# Get inflation-adjusted thresholds for this year.
|
|
201
|
+
threshold15 = gamma_n[n] * capGainRates[status][0] # 0% to 15% threshold
|
|
202
|
+
threshold20 = gamma_n[n] * capGainRates[status][1] # 15% to 20% threshold
|
|
155
203
|
|
|
156
|
-
|
|
204
|
+
# Calculate how much LTCG falls in the 20% bracket.
|
|
205
|
+
# This is the portion of LTCG that pushes total income above threshold20.
|
|
206
|
+
if txIncome_n[n] > threshold20:
|
|
207
|
+
ltcg20 = min(ltcg_n[n], txIncome_n[n] - threshold20)
|
|
208
|
+
else:
|
|
209
|
+
ltcg20 = 0
|
|
210
|
+
|
|
211
|
+
# Calculate how much LTCG falls in the 15% bracket.
|
|
212
|
+
# This is the portion of LTCG in the range [threshold15, threshold20].
|
|
213
|
+
if ordIncome >= threshold20:
|
|
214
|
+
# All LTCG is already in the 20% bracket.
|
|
215
|
+
ltcg15 = 0
|
|
216
|
+
elif txIncome_n[n] > threshold15:
|
|
217
|
+
# Some LTCG falls in the 15% bracket.
|
|
218
|
+
# The 15% bracket spans from threshold15 to threshold20.
|
|
219
|
+
bracket_top = min(threshold20, txIncome_n[n])
|
|
220
|
+
bracket_bottom = max(threshold15, ordIncome)
|
|
221
|
+
ltcg15 = min(ltcg_n[n] - ltcg20, bracket_top - bracket_bottom)
|
|
222
|
+
else:
|
|
223
|
+
# Total income is below the 15% threshold.
|
|
224
|
+
ltcg15 = 0
|
|
225
|
+
|
|
226
|
+
# Remaining LTCG is in the 0% bracket (ltcg0 = ltcg_n[n] - ltcg20 - ltcg15).
|
|
227
|
+
# Calculate tax: 20% on ltcg20, 15% on ltcg15, 0% on remainder.
|
|
228
|
+
cgTax_n[n] = 0.20 * ltcg20 + 0.15 * ltcg15
|
|
229
|
+
|
|
230
|
+
return cgTax_n
|
|
157
231
|
|
|
158
232
|
|
|
159
233
|
def mediCosts(yobs, horizons, magi, prevmagi, gamma_n, Nn):
|
|
@@ -257,7 +331,7 @@ def taxBrackets(N_i, n_d, N_n, yOBBBA=2099):
|
|
|
257
331
|
# Number of years left in OBBBA from this year.
|
|
258
332
|
thisyear = date.today().year
|
|
259
333
|
if yOBBBA < thisyear:
|
|
260
|
-
raise ValueError(f"
|
|
334
|
+
raise ValueError(f"OBBBA expiration year {yOBBBA} cannot be in the past.")
|
|
261
335
|
|
|
262
336
|
ytc = yOBBBA - thisyear
|
|
263
337
|
|
|
@@ -273,7 +347,27 @@ def taxBrackets(N_i, n_d, N_n, yOBBBA=2099):
|
|
|
273
347
|
return data
|
|
274
348
|
|
|
275
349
|
|
|
276
|
-
def
|
|
350
|
+
def computeNIIT(N_i, MAGI_n, I_n, Q_n, n_d, N_n):
|
|
351
|
+
"""
|
|
352
|
+
Compute ACA tax on Dividends (Q) and Interests (I).
|
|
353
|
+
For accounting for rent and/or trust income, one can easily add a column
|
|
354
|
+
to the Wages and Contributions file and add yearly amount to Q_n + I_n below.
|
|
355
|
+
"""
|
|
356
|
+
J_n = np.zeros(N_n)
|
|
357
|
+
status = N_i - 1
|
|
358
|
+
|
|
359
|
+
for n in range(N_n):
|
|
360
|
+
if status and n == n_d:
|
|
361
|
+
status -= 1
|
|
362
|
+
|
|
363
|
+
Gmax = niitThreshold[status]
|
|
364
|
+
if MAGI_n[n] > Gmax:
|
|
365
|
+
J_n[n] = niitRate * min(MAGI_n[n] - Gmax, I_n[n] + Q_n[n])
|
|
366
|
+
|
|
367
|
+
return J_n
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def rho_in(yobs, longevity, N_n):
|
|
277
371
|
"""
|
|
278
372
|
Return Required Minimum Distribution fractions for each individual.
|
|
279
373
|
This implementation does not support spouses with more than
|
|
@@ -316,11 +410,30 @@ def rho_in(yobs, N_n):
|
|
|
316
410
|
5.2,
|
|
317
411
|
4.9,
|
|
318
412
|
4.6,
|
|
413
|
+
4.3,
|
|
414
|
+
4.1,
|
|
415
|
+
3.9,
|
|
416
|
+
3.7,
|
|
417
|
+
3.5,
|
|
418
|
+
3.4,
|
|
419
|
+
3.3,
|
|
420
|
+
3.1,
|
|
421
|
+
3.0,
|
|
422
|
+
2.9,
|
|
423
|
+
2.8,
|
|
424
|
+
2.7,
|
|
425
|
+
2.5,
|
|
426
|
+
2.3,
|
|
427
|
+
2.0
|
|
319
428
|
]
|
|
320
429
|
|
|
321
430
|
N_i = len(yobs)
|
|
322
431
|
if N_i == 2 and abs(yobs[0] - yobs[1]) > 10:
|
|
323
432
|
raise RuntimeError("RMD: Unsupported age difference of more than 10 years.")
|
|
433
|
+
if np.any(np.array(longevity) > 120):
|
|
434
|
+
raise RuntimeError(
|
|
435
|
+
"RMD: Unsupported life expectancy over 120 years."
|
|
436
|
+
)
|
|
324
437
|
|
|
325
438
|
rho = np.zeros((N_i, N_n))
|
|
326
439
|
thisyear = date.today().year
|