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/debts.py
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Debt management and calculation module.
|
|
3
|
+
|
|
4
|
+
This module provides functions for handling debts including mortgage calculations,
|
|
5
|
+
loan amortization, and debt-related financial planning.
|
|
6
|
+
|
|
7
|
+
Copyright (C) 2025-2026 The Owlplanner Authors
|
|
8
|
+
|
|
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.
|
|
13
|
+
|
|
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.
|
|
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/>.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
######################################################################
|
|
24
|
+
import numpy as np
|
|
25
|
+
import pandas as pd # noqa: F401
|
|
26
|
+
from datetime import date
|
|
27
|
+
|
|
28
|
+
from . import utils as u
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def calculate_monthly_payment(principal, annual_rate, term_years):
|
|
32
|
+
"""
|
|
33
|
+
Calculate monthly payment for an amortizing loan. This is a constant payment amount for a fixed-rate loan.
|
|
34
|
+
monthly payment = principal * (monthly_rate * (1 + monthly_rate)**num_payments) /
|
|
35
|
+
((1 + monthly_rate)**num_payments - 1)
|
|
36
|
+
where monthly_rate = annual_rate / 100.0 / 12.0 and num_payments = term_years * 12.0.
|
|
37
|
+
|
|
38
|
+
This formula is derived from the formula for the present value of an annuity.
|
|
39
|
+
|
|
40
|
+
Parameters:
|
|
41
|
+
-----------
|
|
42
|
+
principal : float
|
|
43
|
+
Original loan amount
|
|
44
|
+
annual_rate : float
|
|
45
|
+
Annual interest rate as a percentage (e.g., 4.5 for 4.5%)
|
|
46
|
+
term_years : int
|
|
47
|
+
Loan term in years
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
--------
|
|
51
|
+
float
|
|
52
|
+
Monthly payment amount
|
|
53
|
+
"""
|
|
54
|
+
if term_years <= 0 or annual_rate < 0 or principal <= 0:
|
|
55
|
+
return 0.0
|
|
56
|
+
|
|
57
|
+
monthly_rate = annual_rate / 100.0 / 12.0
|
|
58
|
+
num_payments = term_years * 12
|
|
59
|
+
fac = (1 + monthly_rate)**num_payments
|
|
60
|
+
|
|
61
|
+
if monthly_rate == 0:
|
|
62
|
+
return principal / num_payments
|
|
63
|
+
|
|
64
|
+
payment = principal * (monthly_rate * fac) / (fac - 1)
|
|
65
|
+
|
|
66
|
+
return payment
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def calculate_annual_payment(principal, annual_rate, term_years):
|
|
70
|
+
"""
|
|
71
|
+
Calculate annual payment for an amortizing loan.
|
|
72
|
+
|
|
73
|
+
Parameters:
|
|
74
|
+
-----------
|
|
75
|
+
principal : float
|
|
76
|
+
Original loan amount
|
|
77
|
+
annual_rate : float
|
|
78
|
+
Annual interest rate as a percentage
|
|
79
|
+
term_years : int
|
|
80
|
+
Loan term in years
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
--------
|
|
84
|
+
float
|
|
85
|
+
Annual payment amount
|
|
86
|
+
"""
|
|
87
|
+
return 12 * calculate_monthly_payment(principal, annual_rate, term_years)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def calculate_remaining_balance(principal, annual_rate, term_years, years_elapsed):
|
|
91
|
+
"""
|
|
92
|
+
Calculate remaining balance on a loan after a given number of years.
|
|
93
|
+
|
|
94
|
+
Parameters:
|
|
95
|
+
-----------
|
|
96
|
+
principal : float
|
|
97
|
+
Original loan amount
|
|
98
|
+
annual_rate : float
|
|
99
|
+
Annual interest rate as a percentage
|
|
100
|
+
term_years : int
|
|
101
|
+
Original loan term in years
|
|
102
|
+
years_elapsed : float
|
|
103
|
+
Number of years since loan origination
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
--------
|
|
107
|
+
float
|
|
108
|
+
Remaining balance
|
|
109
|
+
"""
|
|
110
|
+
if years_elapsed <= 0:
|
|
111
|
+
return principal
|
|
112
|
+
|
|
113
|
+
if years_elapsed >= term_years:
|
|
114
|
+
return 0.0
|
|
115
|
+
|
|
116
|
+
monthly_rate = annual_rate / 100.0 / 12.0
|
|
117
|
+
fac = 1 + monthly_rate
|
|
118
|
+
num_payments = term_years * 12
|
|
119
|
+
payments_made = int(years_elapsed * 12)
|
|
120
|
+
|
|
121
|
+
if monthly_rate == 0:
|
|
122
|
+
return principal * (1 - payments_made / num_payments)
|
|
123
|
+
|
|
124
|
+
remaining = principal * (fac**num_payments - fac**payments_made) / (fac**num_payments - 1)
|
|
125
|
+
|
|
126
|
+
return max(0.0, remaining)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def get_debt_payments_for_year(debts_df, year):
|
|
130
|
+
"""
|
|
131
|
+
Calculate total debt payments (principal + interest) for a given year.
|
|
132
|
+
|
|
133
|
+
Parameters:
|
|
134
|
+
-----------
|
|
135
|
+
debts_df : pd.DataFrame
|
|
136
|
+
DataFrame with columns: name, type, year, term, amount, rate
|
|
137
|
+
year : int
|
|
138
|
+
Year for which to calculate payments
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
--------
|
|
142
|
+
float
|
|
143
|
+
Total annual debt payments for the year
|
|
144
|
+
"""
|
|
145
|
+
if u.is_dataframe_empty(debts_df):
|
|
146
|
+
return 0.0
|
|
147
|
+
|
|
148
|
+
total_payments = 0.0
|
|
149
|
+
|
|
150
|
+
for _, debt in debts_df.iterrows():
|
|
151
|
+
# Skip if active column exists and is False (treat NaN/None as True)
|
|
152
|
+
if not u.is_row_active(debt):
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
start_year = int(debt["year"])
|
|
156
|
+
term = int(debt["term"])
|
|
157
|
+
end_year = start_year + term
|
|
158
|
+
|
|
159
|
+
# Check if loan is active in this year
|
|
160
|
+
if start_year <= year < end_year:
|
|
161
|
+
principal = float(debt["amount"])
|
|
162
|
+
rate = float(debt["rate"])
|
|
163
|
+
|
|
164
|
+
# Calculate annual payment (payment amount is constant for fixed-rate loans)
|
|
165
|
+
annual_payment = calculate_annual_payment(principal, rate, term)
|
|
166
|
+
total_payments += annual_payment
|
|
167
|
+
|
|
168
|
+
return total_payments
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def get_debt_balances_for_year(debts_df, year):
|
|
172
|
+
"""
|
|
173
|
+
Calculate total remaining debt balances at the end of a given year.
|
|
174
|
+
|
|
175
|
+
Parameters:
|
|
176
|
+
-----------
|
|
177
|
+
debts_df : pd.DataFrame
|
|
178
|
+
DataFrame with columns: name, type, year, term, amount, rate
|
|
179
|
+
year : int
|
|
180
|
+
Year for which to calculate balances
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
--------
|
|
184
|
+
float
|
|
185
|
+
Total remaining debt balances at end of year
|
|
186
|
+
"""
|
|
187
|
+
if u.is_dataframe_empty(debts_df):
|
|
188
|
+
return 0.0
|
|
189
|
+
|
|
190
|
+
total_balance = 0.0
|
|
191
|
+
|
|
192
|
+
for _, debt in debts_df.iterrows():
|
|
193
|
+
# Skip if active column exists and is False (treat NaN/None as True)
|
|
194
|
+
if not u.is_row_active(debt):
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
start_year = int(debt["year"])
|
|
198
|
+
term = int(debt["term"])
|
|
199
|
+
end_year = start_year + term
|
|
200
|
+
|
|
201
|
+
# Check if loan is active at end of this year
|
|
202
|
+
if start_year <= year < end_year:
|
|
203
|
+
principal = float(debt["amount"])
|
|
204
|
+
rate = float(debt["rate"])
|
|
205
|
+
|
|
206
|
+
# Calculate remaining balance at end of year
|
|
207
|
+
years_elapsed = year - start_year + 1
|
|
208
|
+
remaining_balance = calculate_remaining_balance(principal, rate, term, years_elapsed)
|
|
209
|
+
total_balance += remaining_balance
|
|
210
|
+
|
|
211
|
+
return total_balance
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def get_debt_payments_array(debts_df, N_n, thisyear=None):
|
|
215
|
+
"""
|
|
216
|
+
Process debts_df to provide a single array of length N_n containing
|
|
217
|
+
all annual payments made for each year of the plan.
|
|
218
|
+
|
|
219
|
+
Parameters:
|
|
220
|
+
-----------
|
|
221
|
+
debts_df : pd.DataFrame
|
|
222
|
+
DataFrame with columns: name, type, year, term, amount, rate
|
|
223
|
+
N_n : int
|
|
224
|
+
Number of years in the plan (length of output array)
|
|
225
|
+
thisyear : int, optional
|
|
226
|
+
Starting year of the plan (defaults to date.today().year).
|
|
227
|
+
Array index 0 corresponds to thisyear, index 1 to thisyear+1, etc.
|
|
228
|
+
|
|
229
|
+
Returns:
|
|
230
|
+
--------
|
|
231
|
+
np.ndarray
|
|
232
|
+
Array of length N_n with annual debt payments for each year.
|
|
233
|
+
payments_n[0] = payments for thisyear,
|
|
234
|
+
payments_n[1] = payments for thisyear+1, etc.
|
|
235
|
+
"""
|
|
236
|
+
if thisyear is None:
|
|
237
|
+
thisyear = date.today().year
|
|
238
|
+
|
|
239
|
+
if u.is_dataframe_empty(debts_df):
|
|
240
|
+
return np.zeros(N_n)
|
|
241
|
+
|
|
242
|
+
payments_n = np.zeros(N_n)
|
|
243
|
+
|
|
244
|
+
for _, debt in debts_df.iterrows():
|
|
245
|
+
# Skip if active column exists and is False (treat NaN/None as True)
|
|
246
|
+
if not u.is_row_active(debt):
|
|
247
|
+
continue
|
|
248
|
+
|
|
249
|
+
start_year = int(debt["year"])
|
|
250
|
+
term = int(debt["term"])
|
|
251
|
+
end_year = start_year + term
|
|
252
|
+
principal = float(debt["amount"])
|
|
253
|
+
rate = float(debt["rate"])
|
|
254
|
+
|
|
255
|
+
# Calculate annual payment (payment amount is constant for fixed-rate loans)
|
|
256
|
+
annual_payment = calculate_annual_payment(principal, rate, term)
|
|
257
|
+
|
|
258
|
+
# Add payments for each year the loan is active within the plan horizon
|
|
259
|
+
for n in range(N_n):
|
|
260
|
+
year = thisyear + n
|
|
261
|
+
if start_year <= year < end_year:
|
|
262
|
+
payments_n[n] += annual_payment
|
|
263
|
+
|
|
264
|
+
return payments_n
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def get_remaining_debt_balance(debts_df, N_n, thisyear=None):
|
|
268
|
+
"""
|
|
269
|
+
Calculate total remaining debt balance at the end of the plan horizon.
|
|
270
|
+
Returns the sum of all remaining balances for loans that haven't been
|
|
271
|
+
fully paid off by the end of the plan.
|
|
272
|
+
|
|
273
|
+
Parameters:
|
|
274
|
+
-----------
|
|
275
|
+
debts_df : pd.DataFrame
|
|
276
|
+
DataFrame with columns: name, type, year, term, amount, rate
|
|
277
|
+
N_n : int
|
|
278
|
+
Number of years in the plan
|
|
279
|
+
thisyear : int, optional
|
|
280
|
+
Starting year of the plan (defaults to date.today().year)
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
--------
|
|
284
|
+
float
|
|
285
|
+
Total remaining debt balance at the end of the plan horizon.
|
|
286
|
+
Returns 0.0 if all loans are paid off or if no loans are active.
|
|
287
|
+
"""
|
|
288
|
+
if thisyear is None:
|
|
289
|
+
thisyear = date.today().year
|
|
290
|
+
|
|
291
|
+
if u.is_dataframe_empty(debts_df):
|
|
292
|
+
return 0.0
|
|
293
|
+
|
|
294
|
+
end_year = thisyear + N_n - 1
|
|
295
|
+
total_balance = 0.0
|
|
296
|
+
|
|
297
|
+
for _, debt in debts_df.iterrows():
|
|
298
|
+
# Skip if active column exists and is False (treat NaN/None as True)
|
|
299
|
+
if not u.is_row_active(debt):
|
|
300
|
+
continue
|
|
301
|
+
|
|
302
|
+
start_year = int(debt["year"])
|
|
303
|
+
term = int(debt["term"])
|
|
304
|
+
loan_end_year = start_year + term
|
|
305
|
+
principal = float(debt["amount"])
|
|
306
|
+
rate = float(debt["rate"])
|
|
307
|
+
|
|
308
|
+
# Check if loan is still active at the end of the plan
|
|
309
|
+
if start_year <= end_year < loan_end_year:
|
|
310
|
+
# Calculate remaining balance at end of plan horizon
|
|
311
|
+
years_elapsed = end_year - start_year + 1
|
|
312
|
+
remaining_balance = calculate_remaining_balance(principal, rate, term, years_elapsed)
|
|
313
|
+
total_balance += remaining_balance
|
|
314
|
+
|
|
315
|
+
return total_balance
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Fixed assets management and tax calculation module.
|
|
3
|
+
|
|
4
|
+
This module provides functions for handling fixed assets (such as real estate)
|
|
5
|
+
and calculating tax implications when they are sold or disposed of, including
|
|
6
|
+
primary residence exclusion rules.
|
|
7
|
+
|
|
8
|
+
Copyright (C) 2025-2026 The Owlplanner Authors
|
|
9
|
+
|
|
10
|
+
This program is free software: you can redistribute it and/or modify
|
|
11
|
+
it under the terms of the GNU General Public License as published by
|
|
12
|
+
the Free Software Foundation, either version 3 of the License, or
|
|
13
|
+
(at your option) any later version.
|
|
14
|
+
|
|
15
|
+
This program is distributed in the hope that it will be useful,
|
|
16
|
+
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
17
|
+
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
18
|
+
GNU General Public License for more details.
|
|
19
|
+
|
|
20
|
+
You should have received a copy of the GNU General Public License
|
|
21
|
+
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
######################################################################
|
|
25
|
+
import numpy as np
|
|
26
|
+
import pandas as pd # noqa: F401
|
|
27
|
+
from datetime import date
|
|
28
|
+
|
|
29
|
+
from . import utils as u
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Primary residence exclusion limits (2025 tax year)
|
|
33
|
+
RESIDENCE_EXCLUSION_SINGLE = 250000
|
|
34
|
+
RESIDENCE_EXCLUSION_MARRIED = 500000
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def calculate_future_value(current_value, annual_rate, years):
|
|
38
|
+
"""
|
|
39
|
+
Calculate future value of an asset after a given number of years.
|
|
40
|
+
|
|
41
|
+
Parameters:
|
|
42
|
+
-----------
|
|
43
|
+
current_value : float
|
|
44
|
+
Current value of the asset
|
|
45
|
+
annual_rate : float
|
|
46
|
+
Annual growth rate as a percentage
|
|
47
|
+
years : float
|
|
48
|
+
Number of years to grow
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
--------
|
|
52
|
+
float
|
|
53
|
+
Future value of the asset
|
|
54
|
+
"""
|
|
55
|
+
if years <= 0:
|
|
56
|
+
return current_value
|
|
57
|
+
|
|
58
|
+
growth_factor = (1 + annual_rate / 100.0) ** years
|
|
59
|
+
return current_value * growth_factor
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_fixed_assets_arrays(fixed_assets_df, N_n, thisyear=None, filing_status="single"):
|
|
63
|
+
"""
|
|
64
|
+
Process fixed_assets_df to provide three arrays of length N_n containing:
|
|
65
|
+
1) tax-free money, 2) ordinary income money, and 3) capital gains.
|
|
66
|
+
|
|
67
|
+
Parameters:
|
|
68
|
+
-----------
|
|
69
|
+
fixed_assets_df : pd.DataFrame
|
|
70
|
+
DataFrame with columns: name, type, year, basis, value, rate, yod, commission
|
|
71
|
+
where 'year' is the reference year (this year or after). Basis and
|
|
72
|
+
value are in reference-year dollars.
|
|
73
|
+
N_n : int
|
|
74
|
+
Number of years in the plan (length of output arrays)
|
|
75
|
+
thisyear : int, optional
|
|
76
|
+
Starting year of the plan (defaults to date.today().year).
|
|
77
|
+
Array index 0 corresponds to thisyear, index 1 to thisyear+1, etc.
|
|
78
|
+
filing_status : str, optional
|
|
79
|
+
Filing status: "single" or "married" (defaults to "single").
|
|
80
|
+
Affects primary residence exclusion limits.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
--------
|
|
84
|
+
tuple of np.ndarray
|
|
85
|
+
Three arrays of length N_n:
|
|
86
|
+
- tax_free_n: Tax-free proceeds from asset sales
|
|
87
|
+
- ordinary_income_n: Ordinary income from asset sales (e.g., annuities)
|
|
88
|
+
- capital_gains_n: Capital gains from asset sales
|
|
89
|
+
"""
|
|
90
|
+
if thisyear is None:
|
|
91
|
+
thisyear = date.today().year
|
|
92
|
+
|
|
93
|
+
if u.is_dataframe_empty(fixed_assets_df):
|
|
94
|
+
return np.zeros(N_n), np.zeros(N_n), np.zeros(N_n)
|
|
95
|
+
|
|
96
|
+
tax_free_n = np.zeros(N_n)
|
|
97
|
+
ordinary_income_n = np.zeros(N_n)
|
|
98
|
+
capital_gains_n = np.zeros(N_n)
|
|
99
|
+
|
|
100
|
+
# Determine residence exclusion limit
|
|
101
|
+
if filing_status == "married":
|
|
102
|
+
residence_exclusion = RESIDENCE_EXCLUSION_MARRIED
|
|
103
|
+
else:
|
|
104
|
+
residence_exclusion = RESIDENCE_EXCLUSION_SINGLE
|
|
105
|
+
|
|
106
|
+
for _, asset in fixed_assets_df.iterrows():
|
|
107
|
+
# Skip if active column exists and is False (treat NaN/None as True)
|
|
108
|
+
if not u.is_row_active(asset):
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
asset_type = str(asset["type"]).lower()
|
|
112
|
+
basis = float(asset["basis"])
|
|
113
|
+
value_at_reference = float(asset["value"]) # Value at reference year
|
|
114
|
+
annual_rate = float(asset["rate"])
|
|
115
|
+
# Get reference year, defaulting to thisyear for backward compatibility
|
|
116
|
+
if "year" in asset.index and not pd.isna(asset["year"]):
|
|
117
|
+
reference_year = int(asset["year"])
|
|
118
|
+
else:
|
|
119
|
+
reference_year = thisyear
|
|
120
|
+
yod = int(asset["yod"]) # Year of disposition
|
|
121
|
+
commission_pct = float(asset["commission"]) / 100.0
|
|
122
|
+
|
|
123
|
+
end_year = thisyear + N_n - 1 # Last year of the plan
|
|
124
|
+
|
|
125
|
+
# Skip if asset reference year is after the plan ends
|
|
126
|
+
if reference_year > end_year:
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
# Account for negative or null yod with reference to end of plan
|
|
130
|
+
if yod <= 0:
|
|
131
|
+
yod = end_year + yod + 1
|
|
132
|
+
|
|
133
|
+
# Skip if disposition is before reference year (invalid)
|
|
134
|
+
if yod < reference_year:
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
# Skip if disposition is before the plan starts
|
|
138
|
+
if yod < thisyear:
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
# Only process assets disposed during the plan (yod <= end_year)
|
|
142
|
+
# IMPORTANT: Assets with yod > end_year are NOT processed here to avoid double counting.
|
|
143
|
+
# They are handled separately in get_fixed_assets_bequest_value().
|
|
144
|
+
if yod > end_year:
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
# Disposition at beginning of yod (within plan duration)
|
|
148
|
+
n = yod - thisyear
|
|
149
|
+
# Asset assessed at beginning of reference_year, disposed at beginning of yod
|
|
150
|
+
# Growth period: from start of reference_year to start of yod = (yod - reference_year) years
|
|
151
|
+
years_from_reference_to_disposition = yod - reference_year
|
|
152
|
+
|
|
153
|
+
# Calculate future value at disposition
|
|
154
|
+
future_value = calculate_future_value(value_at_reference, annual_rate, years_from_reference_to_disposition)
|
|
155
|
+
|
|
156
|
+
# Calculate proceeds after commission
|
|
157
|
+
commission_amount = future_value * commission_pct
|
|
158
|
+
proceeds = future_value - commission_amount
|
|
159
|
+
|
|
160
|
+
# Calculate gain (or loss)
|
|
161
|
+
gain = proceeds - basis
|
|
162
|
+
|
|
163
|
+
if asset_type == "fixed annuity":
|
|
164
|
+
# Annuities are taxed as ordinary income
|
|
165
|
+
if gain > 0:
|
|
166
|
+
ordinary_income_n[n] += gain
|
|
167
|
+
# Basis is returned tax-free (even if there's a loss)
|
|
168
|
+
tax_free_n[n] += basis
|
|
169
|
+
elif asset_type == "residence":
|
|
170
|
+
# Primary residence: exclusion up to $250k/$500k
|
|
171
|
+
if gain > 0:
|
|
172
|
+
taxable_gain = max(0, gain - residence_exclusion)
|
|
173
|
+
if taxable_gain > 0:
|
|
174
|
+
capital_gains_n[n] += taxable_gain
|
|
175
|
+
# Excluded gain is tax-free
|
|
176
|
+
tax_free_n[n] += basis + min(gain, residence_exclusion)
|
|
177
|
+
else:
|
|
178
|
+
# Loss or no gain: proceeds are tax-free
|
|
179
|
+
tax_free_n[n] += proceeds
|
|
180
|
+
elif asset_type in ["collectibles", "precious metals"]:
|
|
181
|
+
# Collectibles and precious metals: special capital gains treatment
|
|
182
|
+
# (28% max rate, but we just report as capital gains here)
|
|
183
|
+
if gain > 0:
|
|
184
|
+
capital_gains_n[n] += gain
|
|
185
|
+
tax_free_n[n] += basis
|
|
186
|
+
else:
|
|
187
|
+
# Loss: only proceeds are tax-free
|
|
188
|
+
tax_free_n[n] += proceeds
|
|
189
|
+
elif asset_type in ["real estate", "stocks"]:
|
|
190
|
+
# Real estate and stocks: standard capital gains
|
|
191
|
+
if gain > 0:
|
|
192
|
+
capital_gains_n[n] += gain
|
|
193
|
+
tax_free_n[n] += basis
|
|
194
|
+
else:
|
|
195
|
+
# Loss: only proceeds are tax-free
|
|
196
|
+
tax_free_n[n] += proceeds
|
|
197
|
+
else:
|
|
198
|
+
# Unknown type: treat as capital gains
|
|
199
|
+
if gain > 0:
|
|
200
|
+
capital_gains_n[n] += gain
|
|
201
|
+
tax_free_n[n] += basis
|
|
202
|
+
else:
|
|
203
|
+
# Loss: only proceeds are tax-free
|
|
204
|
+
tax_free_n[n] += proceeds
|
|
205
|
+
|
|
206
|
+
return tax_free_n, ordinary_income_n, capital_gains_n
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def get_fixed_assets_bequest_value(fixed_assets_df, N_n, thisyear=None):
|
|
210
|
+
"""
|
|
211
|
+
Calculate the total bequest value from fixed assets that have a yod
|
|
212
|
+
(year of disposition) past the end of the plan. These assets are assumed
|
|
213
|
+
to be liquidated at the end of the plan and added to the bequest.
|
|
214
|
+
|
|
215
|
+
Parameters:
|
|
216
|
+
-----------
|
|
217
|
+
fixed_assets_df : pd.DataFrame
|
|
218
|
+
DataFrame with columns: name, type, year, basis, value, rate, yod, commission
|
|
219
|
+
where 'year' is the reference year (this year or after). Basis and
|
|
220
|
+
value are in reference-year dollars.
|
|
221
|
+
N_n : int
|
|
222
|
+
Number of years in the plan
|
|
223
|
+
thisyear : int, optional
|
|
224
|
+
Starting year of the plan (defaults to date.today().year)
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
--------
|
|
228
|
+
float
|
|
229
|
+
Total proceeds (after commission) from assets liquidated at end of plan.
|
|
230
|
+
This represents the total value added to the bequest. No taxes are applied
|
|
231
|
+
as assets are assumed to pass to heirs with step-up in basis.
|
|
232
|
+
"""
|
|
233
|
+
if thisyear is None:
|
|
234
|
+
thisyear = date.today().year
|
|
235
|
+
|
|
236
|
+
if u.is_dataframe_empty(fixed_assets_df):
|
|
237
|
+
return 0.0
|
|
238
|
+
|
|
239
|
+
end_year = thisyear + N_n - 1 # Last year of the plan
|
|
240
|
+
total_bequest_value = 0.0
|
|
241
|
+
|
|
242
|
+
for _, asset in fixed_assets_df.iterrows():
|
|
243
|
+
# Skip if active column exists and is False (treat NaN/None as True)
|
|
244
|
+
if not u.is_row_active(asset):
|
|
245
|
+
continue
|
|
246
|
+
|
|
247
|
+
# Get reference year, defaulting to thisyear for backward compatibility
|
|
248
|
+
if "year" in asset.index and not pd.isna(asset["year"]):
|
|
249
|
+
reference_year = int(asset["year"])
|
|
250
|
+
else:
|
|
251
|
+
reference_year = thisyear
|
|
252
|
+
yod = int(asset["yod"]) # Year of disposition
|
|
253
|
+
|
|
254
|
+
# Skip if asset reference year is after the plan ends
|
|
255
|
+
if reference_year > end_year:
|
|
256
|
+
continue
|
|
257
|
+
|
|
258
|
+
# Account for negative or null yod with reference to end of plan
|
|
259
|
+
if yod <= 0:
|
|
260
|
+
yod = end_year + yod + 1
|
|
261
|
+
|
|
262
|
+
# Skip if disposition is before reference year (invalid)
|
|
263
|
+
if yod < reference_year:
|
|
264
|
+
continue
|
|
265
|
+
|
|
266
|
+
# Only consider assets with yod past the end of the plan (not disposed during the plan)
|
|
267
|
+
# IMPORTANT: Assets with yod <= end_year are NOT processed here to avoid double counting.
|
|
268
|
+
# They are handled separately in get_fixed_assets_arrays() where they are disposed during the plan.
|
|
269
|
+
# These assets (yod > end_year) are assumed to be liquidated at the end of the plan and added to the bequest
|
|
270
|
+
if yod > end_year:
|
|
271
|
+
value_at_reference = float(asset["value"]) # Value at reference year
|
|
272
|
+
annual_rate = float(asset["rate"])
|
|
273
|
+
commission_pct = float(asset["commission"]) / 100.0
|
|
274
|
+
|
|
275
|
+
# Calculate future value at the end of the plan
|
|
276
|
+
# Asset assessed at beginning of reference_year, liquidated at end of end_year
|
|
277
|
+
# Growth period: from start of reference_year to end of end_year = (end_year - reference_year + 1) years
|
|
278
|
+
years_from_reference_to_end = end_year - reference_year + 1
|
|
279
|
+
future_value = calculate_future_value(value_at_reference, annual_rate, years_from_reference_to_end)
|
|
280
|
+
|
|
281
|
+
# Calculate proceeds after commission
|
|
282
|
+
commission_amount = future_value * commission_pct
|
|
283
|
+
proceeds = future_value - commission_amount
|
|
284
|
+
|
|
285
|
+
# Add to total bequest value (full proceeds, no tax - step-up in basis for heirs)
|
|
286
|
+
total_bequest_value += proceeds
|
|
287
|
+
|
|
288
|
+
return total_bequest_value
|