owlplanner 2025.12.20__py3-none-any.whl → 2026.2.2__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/__init__.py +20 -1
- owlplanner/abcapi.py +18 -17
- 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 -118
- owlplanner/data/__init__.py +21 -0
- owlplanner/data/rates.csv +99 -98
- owlplanner/debts.py +36 -8
- owlplanner/fixedassets.py +95 -21
- owlplanner/mylogging.py +157 -25
- owlplanner/plan.py +938 -390
- 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 +32 -9
- owlplanner/progress.py +16 -3
- owlplanner/rates.py +50 -34
- owlplanner/socialsecurity.py +28 -19
- owlplanner/tax2026.py +119 -38
- owlplanner/timelists.py +194 -18
- owlplanner/utils.py +179 -4
- owlplanner/version.py +20 -1
- {owlplanner-2025.12.20.dist-info → owlplanner-2026.2.2.dist-info}/METADATA +11 -3
- owlplanner-2026.2.2.dist-info/RECORD +35 -0
- owlplanner-2026.2.2.dist-info/entry_points.txt +2 -0
- owlplanner-2026.2.2.dist-info/licenses/AUTHORS +15 -0
- owlplanner/tax2025.py +0 -359
- owlplanner-2025.12.20.dist-info/RECORD +0 -29
- {owlplanner-2025.12.20.dist-info → owlplanner-2026.2.2.dist-info}/WHEEL +0 -0
- {owlplanner-2025.12.20.dist-info → owlplanner-2026.2.2.dist-info}/licenses/LICENSE +0 -0
owlplanner/tax2026.py
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
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
|
|
@@ -41,10 +45,12 @@ irmaaBrackets = np.array(
|
|
|
41
45
|
]
|
|
42
46
|
)
|
|
43
47
|
|
|
44
|
-
# These are current for 2026 (2025TY).
|
|
45
|
-
# Index [0] stores the standard Medicare
|
|
46
|
-
# Following values are incremental IRMAA
|
|
48
|
+
# These are current for 2026 (2025TY). Source: CMS 2026 Part B premiums and IRMAA.
|
|
49
|
+
# Index [0] stores the standard Medicare Part B basic premium (monthly $202.90 for 2026).
|
|
50
|
+
# Following values are incremental IRMAA Part B monthly fees; cumulative = total/month.
|
|
51
|
+
# Single brackets [0]: ≤$109k, $109–137k, $137–171k, $171–205k, $205–500k, ≥$500k.
|
|
47
52
|
irmaaFees = 12 * np.array([202.90, 81.20, 121.70, 121.70, 121.70, 40.70])
|
|
53
|
+
irmaaCosts = np.cumsum(irmaaFees)
|
|
48
54
|
|
|
49
55
|
#########################################################################
|
|
50
56
|
# Make projection for pre-TCJA using 2017 to current year.
|
|
@@ -116,58 +122,114 @@ def mediVals(yobs, horizons, gamma_n, Nn, Nq):
|
|
|
116
122
|
"""
|
|
117
123
|
Return tuple (nm, L, C) of year index when Medicare starts and vectors L, and C
|
|
118
124
|
defining end points of constant piecewise linear functions representing IRMAA fees.
|
|
125
|
+
Costs C include the fact that one or two indivuals have to pay. Eligibility is built-in.
|
|
119
126
|
"""
|
|
120
127
|
thisyear = date.today().year
|
|
121
|
-
assert Nq == len(
|
|
128
|
+
assert Nq == len(irmaaCosts), f"Inconsistent value of Nq: {Nq}."
|
|
122
129
|
assert Nq == len(irmaaBrackets[0]), "Inconsistent IRMAA brackets array."
|
|
123
130
|
Ni = len(yobs)
|
|
124
|
-
# What index year will Medicare start? 65 - age.
|
|
125
|
-
nm = 65 -
|
|
126
|
-
nm = np.
|
|
131
|
+
# What index year will Medicare start? 65 - age for each individual.
|
|
132
|
+
nm = yobs + 65 - thisyear
|
|
133
|
+
nm = np.maximum(0, nm)
|
|
134
|
+
nmstart = np.min(nm)
|
|
127
135
|
# Has it already started?
|
|
128
|
-
|
|
129
|
-
Nmed = Nn - nm
|
|
136
|
+
Nmed = Nn - nmstart
|
|
130
137
|
|
|
131
|
-
|
|
132
|
-
|
|
138
|
+
Lbar = np.zeros((Nmed, Nq-1))
|
|
139
|
+
Cbar = np.zeros((Nmed, Nq))
|
|
133
140
|
|
|
134
|
-
# Year starts at offset
|
|
141
|
+
# Year starts at offset nmstart in the plan. L and C arrays are shorter.
|
|
135
142
|
for nn in range(Nmed):
|
|
136
143
|
imed = 0
|
|
137
|
-
n =
|
|
144
|
+
n = nmstart + nn
|
|
138
145
|
if thisyear + n - yobs[0] >= 65 and n < horizons[0]:
|
|
139
146
|
imed += 1
|
|
140
147
|
if Ni == 2 and thisyear + n - yobs[1] >= 65 and n < horizons[1]:
|
|
141
148
|
imed += 1
|
|
142
149
|
if imed:
|
|
143
150
|
status = 0 if Ni == 1 else 1 if n < horizons[0] and n < horizons[1] else 0
|
|
144
|
-
|
|
145
|
-
|
|
151
|
+
Lbar[nn] = gamma_n[n] * irmaaBrackets[status][1:]
|
|
152
|
+
Cbar[nn] = imed * gamma_n[n] * irmaaCosts
|
|
146
153
|
else:
|
|
147
154
|
raise RuntimeError("mediVals: This should never happen.")
|
|
148
155
|
|
|
149
|
-
return
|
|
156
|
+
return nmstart, Lbar, Cbar
|
|
150
157
|
|
|
151
158
|
|
|
152
|
-
def
|
|
159
|
+
def capitalGainTax(Ni, txIncome_n, ltcg_n, gamma_n, nd, Nn):
|
|
153
160
|
"""
|
|
154
|
-
Return an array of
|
|
155
|
-
|
|
156
|
-
|
|
161
|
+
Return an array of tax on capital gains.
|
|
162
|
+
|
|
163
|
+
Parameters:
|
|
164
|
+
-----------
|
|
165
|
+
Ni : int
|
|
166
|
+
Number of individuals (1 or 2)
|
|
167
|
+
txIncome_n : array
|
|
168
|
+
Array of taxable income for each year (ordinary income + capital gains)
|
|
169
|
+
ltcg_n : array
|
|
170
|
+
Array of long-term capital gains for each year
|
|
171
|
+
gamma_n : array
|
|
172
|
+
Array of inflation adjustment factors for each year
|
|
173
|
+
nd : int
|
|
174
|
+
Index year of first passing of a spouse, if applicable (nd == Nn for single individuals)
|
|
175
|
+
Nn : int
|
|
176
|
+
Total number of years in the plan
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
--------
|
|
180
|
+
cgTax_n : array
|
|
181
|
+
Array of tax on capital gains for each year
|
|
182
|
+
|
|
183
|
+
Notes:
|
|
184
|
+
------
|
|
185
|
+
Thresholds are determined by the taxable income which is roughly AGI - (standard/itemized) deductions.
|
|
186
|
+
Taxable income can also be thought of as taxable ordinary income + capital gains.
|
|
187
|
+
|
|
188
|
+
Long-term capital gains are taxed at 0%, 15%, or 20% based on total taxable income.
|
|
189
|
+
Capital gains "stack on top" of ordinary income, so the portion of gains that
|
|
190
|
+
pushes total income above each threshold is taxed at the corresponding rate.
|
|
157
191
|
"""
|
|
158
192
|
status = Ni - 1
|
|
159
|
-
|
|
193
|
+
cgTax_n = np.zeros(Nn)
|
|
160
194
|
|
|
161
195
|
for n in range(Nn):
|
|
162
196
|
if status and n == nd:
|
|
163
197
|
status -= 1
|
|
164
198
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
199
|
+
# Calculate ordinary income (taxable income minus capital gains).
|
|
200
|
+
ordIncome = txIncome_n[n] - ltcg_n[n]
|
|
201
|
+
|
|
202
|
+
# Get inflation-adjusted thresholds for this year.
|
|
203
|
+
threshold15 = gamma_n[n] * capGainRates[status][0] # 0% to 15% threshold
|
|
204
|
+
threshold20 = gamma_n[n] * capGainRates[status][1] # 15% to 20% threshold
|
|
205
|
+
|
|
206
|
+
# Calculate how much LTCG falls in the 20% bracket.
|
|
207
|
+
# This is the portion of LTCG that pushes total income above threshold20.
|
|
208
|
+
if txIncome_n[n] > threshold20:
|
|
209
|
+
ltcg20 = min(ltcg_n[n], txIncome_n[n] - threshold20)
|
|
210
|
+
else:
|
|
211
|
+
ltcg20 = 0
|
|
212
|
+
|
|
213
|
+
# Calculate how much LTCG falls in the 15% bracket.
|
|
214
|
+
# This is the portion of LTCG in the range [threshold15, threshold20].
|
|
215
|
+
if ordIncome >= threshold20:
|
|
216
|
+
# All LTCG is already in the 20% bracket.
|
|
217
|
+
ltcg15 = 0
|
|
218
|
+
elif txIncome_n[n] > threshold15:
|
|
219
|
+
# Some LTCG falls in the 15% bracket.
|
|
220
|
+
# The 15% bracket spans from threshold15 to threshold20.
|
|
221
|
+
bracket_top = min(threshold20, txIncome_n[n])
|
|
222
|
+
bracket_bottom = max(threshold15, ordIncome)
|
|
223
|
+
ltcg15 = min(ltcg_n[n] - ltcg20, bracket_top - bracket_bottom)
|
|
224
|
+
else:
|
|
225
|
+
# Total income is below the 15% threshold.
|
|
226
|
+
ltcg15 = 0
|
|
227
|
+
|
|
228
|
+
# Remaining LTCG is in the 0% bracket (ltcg0 = ltcg_n[n] - ltcg20 - ltcg15).
|
|
229
|
+
# Calculate tax: 20% on ltcg20, 15% on ltcg15, 0% on remainder.
|
|
230
|
+
cgTax_n[n] = 0.20 * ltcg20 + 0.15 * ltcg15
|
|
169
231
|
|
|
170
|
-
return
|
|
232
|
+
return cgTax_n
|
|
171
233
|
|
|
172
234
|
|
|
173
235
|
def mediCosts(yobs, horizons, magi, prevmagi, gamma_n, Nn):
|
|
@@ -271,7 +333,7 @@ def taxBrackets(N_i, n_d, N_n, yOBBBA=2099):
|
|
|
271
333
|
# Number of years left in OBBBA from this year.
|
|
272
334
|
thisyear = date.today().year
|
|
273
335
|
if yOBBBA < thisyear:
|
|
274
|
-
raise ValueError(f"
|
|
336
|
+
raise ValueError(f"OBBBA expiration year {yOBBBA} cannot be in the past.")
|
|
275
337
|
|
|
276
338
|
ytc = yOBBBA - thisyear
|
|
277
339
|
|
|
@@ -307,7 +369,7 @@ def computeNIIT(N_i, MAGI_n, I_n, Q_n, n_d, N_n):
|
|
|
307
369
|
return J_n
|
|
308
370
|
|
|
309
371
|
|
|
310
|
-
def rho_in(yobs, N_n):
|
|
372
|
+
def rho_in(yobs, longevity, N_n):
|
|
311
373
|
"""
|
|
312
374
|
Return Required Minimum Distribution fractions for each individual.
|
|
313
375
|
This implementation does not support spouses with more than
|
|
@@ -350,11 +412,30 @@ def rho_in(yobs, N_n):
|
|
|
350
412
|
5.2,
|
|
351
413
|
4.9,
|
|
352
414
|
4.6,
|
|
415
|
+
4.3,
|
|
416
|
+
4.1,
|
|
417
|
+
3.9,
|
|
418
|
+
3.7,
|
|
419
|
+
3.5,
|
|
420
|
+
3.4,
|
|
421
|
+
3.3,
|
|
422
|
+
3.1,
|
|
423
|
+
3.0,
|
|
424
|
+
2.9,
|
|
425
|
+
2.8,
|
|
426
|
+
2.7,
|
|
427
|
+
2.5,
|
|
428
|
+
2.3,
|
|
429
|
+
2.0
|
|
353
430
|
]
|
|
354
431
|
|
|
355
432
|
N_i = len(yobs)
|
|
356
433
|
if N_i == 2 and abs(yobs[0] - yobs[1]) > 10:
|
|
357
434
|
raise RuntimeError("RMD: Unsupported age difference of more than 10 years.")
|
|
435
|
+
if np.any(np.array(longevity) > 120):
|
|
436
|
+
raise RuntimeError(
|
|
437
|
+
"RMD: Unsupported life expectancy over 120 years."
|
|
438
|
+
)
|
|
358
439
|
|
|
359
440
|
rho = np.zeros((N_i, N_n))
|
|
360
441
|
thisyear = date.today().year
|
owlplanner/timelists.py
CHANGED
|
@@ -1,24 +1,30 @@
|
|
|
1
1
|
"""
|
|
2
|
+
Time horizon data validation and processing utilities.
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
4
|
+
This module provides utility functions to read and validate timelist data
|
|
5
|
+
from Excel files, including wage, contribution, and other time-based parameters.
|
|
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
|
from datetime import date
|
|
20
24
|
import pandas as pd
|
|
21
25
|
|
|
26
|
+
from . import utils as u
|
|
27
|
+
|
|
22
28
|
|
|
23
29
|
# Expected headers in each excel sheet, one per individual.
|
|
24
30
|
_timeHorizonItems = [
|
|
@@ -35,6 +41,7 @@ _timeHorizonItems = [
|
|
|
35
41
|
|
|
36
42
|
|
|
37
43
|
_debtItems = [
|
|
44
|
+
"active",
|
|
38
45
|
"name",
|
|
39
46
|
"type",
|
|
40
47
|
"year",
|
|
@@ -51,8 +58,10 @@ _debtTypes = [
|
|
|
51
58
|
|
|
52
59
|
|
|
53
60
|
_fixedAssetItems = [
|
|
61
|
+
"active",
|
|
54
62
|
"name",
|
|
55
63
|
"type",
|
|
64
|
+
"year",
|
|
56
65
|
"basis",
|
|
57
66
|
"value",
|
|
58
67
|
"rate",
|
|
@@ -71,7 +80,7 @@ _fixedAssetTypes = [
|
|
|
71
80
|
]
|
|
72
81
|
|
|
73
82
|
|
|
74
|
-
def read(finput, inames, horizons, mylog):
|
|
83
|
+
def read(finput, inames, horizons, mylog, filename=None):
|
|
75
84
|
"""
|
|
76
85
|
Read listed parameters from an excel spreadsheet or through
|
|
77
86
|
a dictionary of dataframes through Pandas.
|
|
@@ -80,6 +89,20 @@ def read(finput, inames, horizons, mylog):
|
|
|
80
89
|
IRA ctrb, Roth IRA ctrb, Roth conv, and big-ticket items.
|
|
81
90
|
Supports xls, xlsx, xlsm, xlsb, odf, ods, and odt file extensions.
|
|
82
91
|
Return a dictionary of dataframes by individual's names.
|
|
92
|
+
|
|
93
|
+
Parameters
|
|
94
|
+
----------
|
|
95
|
+
finput : file-like object, str, or dict
|
|
96
|
+
Input file or dictionary of DataFrames
|
|
97
|
+
inames : list
|
|
98
|
+
List of individual names
|
|
99
|
+
horizons : list
|
|
100
|
+
List of time horizons
|
|
101
|
+
mylog : logger
|
|
102
|
+
Logger instance
|
|
103
|
+
filename : str, optional
|
|
104
|
+
Explicit filename for logging purposes. If provided, this will be used
|
|
105
|
+
instead of trying to extract it from finput.
|
|
83
106
|
"""
|
|
84
107
|
|
|
85
108
|
mylog.vprint("Reading wages, contributions, conversions, and big-ticket items over time...")
|
|
@@ -89,13 +112,19 @@ def read(finput, inames, horizons, mylog):
|
|
|
89
112
|
finput = "dictionary of DataFrames"
|
|
90
113
|
streamName = "dictionary of DataFrames"
|
|
91
114
|
else:
|
|
115
|
+
if filename is not None:
|
|
116
|
+
streamName = f"file '{filename}'"
|
|
117
|
+
elif hasattr(finput, "name"):
|
|
118
|
+
streamName = f"file '{finput.name}'"
|
|
119
|
+
else:
|
|
120
|
+
streamName = finput
|
|
121
|
+
|
|
92
122
|
# Read all worksheets in memory but only process those with proper names.
|
|
93
123
|
try:
|
|
94
124
|
# dfDict = pd.read_excel(finput, sheet_name=None, usecols=_timeHorizonItems)
|
|
95
125
|
dfDict = pd.read_excel(finput, sheet_name=None)
|
|
96
126
|
except Exception as e:
|
|
97
|
-
raise Exception(f"Could not read file {
|
|
98
|
-
streamName = f"file '{finput}'"
|
|
127
|
+
raise Exception(f"Could not read file {streamName}: {e}.") from e
|
|
99
128
|
|
|
100
129
|
timeLists = _conditionTimetables(dfDict, inames, horizons, mylog)
|
|
101
130
|
mylog.vprint(f"Successfully read time horizons from {streamName}.")
|
|
@@ -111,10 +140,13 @@ def _checkColumns(df, iname, colList):
|
|
|
111
140
|
Ensure all columns in colList are present. Remove others.
|
|
112
141
|
"""
|
|
113
142
|
# Drop all columns not in the list.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
143
|
+
# Make an explicit copy to avoid SettingWithCopyWarning
|
|
144
|
+
df = df.loc[:, ~df.columns.str.contains("^Unnamed")].copy()
|
|
145
|
+
|
|
146
|
+
# Collect columns to drop
|
|
147
|
+
cols_to_drop = [col for col in df.columns if col == "" or col not in colList]
|
|
148
|
+
if cols_to_drop:
|
|
149
|
+
df = df.drop(cols_to_drop, axis=1)
|
|
118
150
|
|
|
119
151
|
# Check that all columns in the list are present.
|
|
120
152
|
for item in colList:
|
|
@@ -141,6 +173,9 @@ def _conditionTimetables(dfDict, inames, horizons, mylog):
|
|
|
141
173
|
|
|
142
174
|
df = _checkColumns(df, iname, _timeHorizonItems)
|
|
143
175
|
|
|
176
|
+
# Ensure columns are in the correct order
|
|
177
|
+
df = df[_timeHorizonItems].copy()
|
|
178
|
+
|
|
144
179
|
# Only consider lines in proper year range. Go back 5 years for Roth maturation.
|
|
145
180
|
df = df[df["year"] >= (thisyear - 5)]
|
|
146
181
|
df = df[df["year"] < endyear]
|
|
@@ -150,7 +185,10 @@ def _conditionTimetables(dfDict, inames, horizons, mylog):
|
|
|
150
185
|
year = thisyear + n
|
|
151
186
|
year_rows = df[df["year"] == year]
|
|
152
187
|
if year_rows.empty:
|
|
153
|
-
|
|
188
|
+
# Create a new row as a dictionary to ensure correct column mapping.
|
|
189
|
+
new_row = {col: 0 for col in _timeHorizonItems}
|
|
190
|
+
new_row["year"] = year
|
|
191
|
+
df = pd.concat([df, pd.DataFrame([new_row])], ignore_index=True)
|
|
154
192
|
missing.append(year)
|
|
155
193
|
else:
|
|
156
194
|
for item in _timeHorizonItems:
|
|
@@ -220,6 +258,21 @@ def _conditionHouseTables(dfDict, mylog):
|
|
|
220
258
|
f"Converted {mask.sum()} commission value(s) from decimal "
|
|
221
259
|
f"to percentage in Fixed Assets table."
|
|
222
260
|
)
|
|
261
|
+
# Validate and reset "year" column (reference year) if in the past
|
|
262
|
+
if "year" in df.columns:
|
|
263
|
+
thisyear = date.today().year
|
|
264
|
+
mask = df["year"] < thisyear
|
|
265
|
+
if mask.any():
|
|
266
|
+
df.loc[mask, "year"] = thisyear
|
|
267
|
+
mylog.vprint(
|
|
268
|
+
f"Reset {mask.sum()} reference year value(s) to {thisyear} "
|
|
269
|
+
f"in Fixed Assets table (years cannot be in the past)."
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# Convert "active" column to boolean if it exists.
|
|
273
|
+
# Excel may read booleans as strings ("True"/"False") or numbers (1/0).
|
|
274
|
+
if "active" in df.columns:
|
|
275
|
+
df["active"] = df["active"].apply(u.convert_to_bool).astype(bool)
|
|
223
276
|
|
|
224
277
|
houseDic[page] = df
|
|
225
278
|
mylog.vprint(f"Found {len(df)} valid row(s) in {page} table.")
|
|
@@ -228,3 +281,126 @@ def _conditionHouseTables(dfDict, mylog):
|
|
|
228
281
|
mylog.vprint(f"Table for {page} not found. Assuming empty table.")
|
|
229
282
|
|
|
230
283
|
return houseDic
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def conditionDebtsAndFixedAssetsDF(df, tableType, mylog=None):
|
|
287
|
+
"""
|
|
288
|
+
Condition a DataFrame for Debts or Fixed Assets by:
|
|
289
|
+
- Creating an empty DataFrame with proper columns if df is None or empty
|
|
290
|
+
- Resetting the index
|
|
291
|
+
- Filling NaN values with 0 while preserving boolean columns (like "active")
|
|
292
|
+
|
|
293
|
+
Parameters
|
|
294
|
+
----------
|
|
295
|
+
df : pandas.DataFrame or None
|
|
296
|
+
The DataFrame to condition, or None/empty to create a new empty DataFrame
|
|
297
|
+
tableType : str
|
|
298
|
+
Type of table: "Debts" or "Fixed Assets"
|
|
299
|
+
mylog : logger, optional
|
|
300
|
+
Logger instance for optional UI/log output
|
|
301
|
+
|
|
302
|
+
Returns
|
|
303
|
+
-------
|
|
304
|
+
pandas.DataFrame
|
|
305
|
+
Conditioned DataFrame with proper columns and no NaN values (except boolean columns default to True)
|
|
306
|
+
"""
|
|
307
|
+
# Map table type to column items
|
|
308
|
+
items = {"Debts": _debtItems, "Fixed Assets": _fixedAssetItems}
|
|
309
|
+
if tableType not in items:
|
|
310
|
+
raise ValueError(f"tableType must be 'Debts' or 'Fixed Assets', got '{tableType}'")
|
|
311
|
+
|
|
312
|
+
columnItems = items[tableType]
|
|
313
|
+
|
|
314
|
+
df = u.ensure_dataframe(df, pd.DataFrame(columns=columnItems))
|
|
315
|
+
|
|
316
|
+
df = df.copy()
|
|
317
|
+
df.reset_index(drop=True, inplace=True)
|
|
318
|
+
|
|
319
|
+
# Ensure all required columns exist
|
|
320
|
+
for col in columnItems:
|
|
321
|
+
if col not in df.columns:
|
|
322
|
+
df[col] = None
|
|
323
|
+
|
|
324
|
+
# Only keep the columns we need, in the correct order
|
|
325
|
+
df = df[columnItems].copy()
|
|
326
|
+
|
|
327
|
+
# Define which columns are integers vs floats
|
|
328
|
+
if tableType == "Debts":
|
|
329
|
+
int_cols = ["year", "term"]
|
|
330
|
+
float_cols = ["amount", "rate"]
|
|
331
|
+
else: # Fixed Assets
|
|
332
|
+
int_cols = ["year", "yod"]
|
|
333
|
+
float_cols = ["basis", "value", "rate", "commission"]
|
|
334
|
+
|
|
335
|
+
# Handle empty DataFrame by setting dtypes directly
|
|
336
|
+
if len(df) == 0:
|
|
337
|
+
dtype_dict = {}
|
|
338
|
+
dtype_dict["active"] = bool
|
|
339
|
+
for col in ["name", "type"]:
|
|
340
|
+
dtype_dict[col] = "object" # string columns
|
|
341
|
+
for col in int_cols:
|
|
342
|
+
dtype_dict[col] = "int64"
|
|
343
|
+
for col in float_cols:
|
|
344
|
+
dtype_dict[col] = "float64"
|
|
345
|
+
df = df.astype(dtype_dict)
|
|
346
|
+
else:
|
|
347
|
+
# Fill NaN values and ensure proper types for non-empty DataFrame
|
|
348
|
+
for col in df.columns:
|
|
349
|
+
if col == "active":
|
|
350
|
+
# Ensure "active" column is boolean, handling strings/numbers from Excel
|
|
351
|
+
df[col] = df[col].apply(u.convert_to_bool).astype(bool)
|
|
352
|
+
elif col in ["name", "type"]:
|
|
353
|
+
# String columns: ensure they are strings, not lists
|
|
354
|
+
# Streamlit data_editor can return lists for string columns in some cases
|
|
355
|
+
def convert_to_string(val):
|
|
356
|
+
if pd.isna(val) or val is None:
|
|
357
|
+
return ""
|
|
358
|
+
if isinstance(val, list):
|
|
359
|
+
# If it's a list, join the elements (handles Streamlit data_editor edge cases)
|
|
360
|
+
# Filter out None/NaN values before joining
|
|
361
|
+
cleaned = [str(x) for x in val if x is not None and not pd.isna(x)]
|
|
362
|
+
return " ".join(cleaned) if cleaned else ""
|
|
363
|
+
return str(val)
|
|
364
|
+
|
|
365
|
+
df[col] = df[col].apply(convert_to_string).astype(str)
|
|
366
|
+
# Replace "nan" string with empty string
|
|
367
|
+
df[col] = df[col].replace("nan", "").replace("None", "")
|
|
368
|
+
elif col in int_cols:
|
|
369
|
+
# Integer columns: convert to int64, fill NaN with 0
|
|
370
|
+
df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0).astype("int64")
|
|
371
|
+
elif col in float_cols:
|
|
372
|
+
# Float columns: convert to float64, fill NaN with 0.0
|
|
373
|
+
df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0.0).astype("float64")
|
|
374
|
+
|
|
375
|
+
# For Fixed Assets, validate and reset "year" column if in the past
|
|
376
|
+
if tableType == "Fixed Assets" and "year" in df.columns and len(df) > 0:
|
|
377
|
+
thisyear = date.today().year
|
|
378
|
+
mask = df["year"] < thisyear
|
|
379
|
+
if mask.any():
|
|
380
|
+
df.loc[mask, "year"] = thisyear
|
|
381
|
+
|
|
382
|
+
if mylog is not None:
|
|
383
|
+
mylog.vprint(f"Found {len(df)} valid row(s) in {tableType} table.")
|
|
384
|
+
|
|
385
|
+
return df
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def getTableTypes(tableType):
|
|
389
|
+
"""
|
|
390
|
+
Get the list of valid types for a given table type.
|
|
391
|
+
|
|
392
|
+
Parameters
|
|
393
|
+
----------
|
|
394
|
+
tableType : str
|
|
395
|
+
Type of table: "Debts" or "Fixed Assets"
|
|
396
|
+
|
|
397
|
+
Returns
|
|
398
|
+
-------
|
|
399
|
+
list
|
|
400
|
+
List of valid types for the specified table
|
|
401
|
+
"""
|
|
402
|
+
types = {"Debts": _debtTypes, "Fixed Assets": _fixedAssetTypes}
|
|
403
|
+
if tableType not in types:
|
|
404
|
+
raise ValueError(f"tableType must be 'Debts' or 'Fixed Assets', got '{tableType}'")
|
|
405
|
+
|
|
406
|
+
return types[tableType]
|