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/tax2026.py CHANGED
@@ -1,19 +1,23 @@
1
1
  """
2
+ Tax calculation module for 2026 tax year rules.
2
3
 
3
- Owl/tax2026
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
- A retirement planner using linear programming optimization.
7
+ Copyright (C) 2025-2026 The Owlplanner Authors
7
8
 
8
- See companion document for a complete explanation and description
9
- of all variables and parameters.
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
- Module to handle all tax calculations.
12
-
13
- Copyright © 2026 - Martin-D. Lacasse
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 part B basic premium.
46
- # Following values are incremental IRMAA part B monthly fees.
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(irmaaFees), f"Inconsistent value of Nq: {Nq}."
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 - (thisyear - yobs)
126
- nm = np.min(nm)
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
- nm = max(0, nm)
129
- Nmed = Nn - nm
136
+ Nmed = Nn - nmstart
130
137
 
131
- L = np.zeros((Nmed, Nq-1))
132
- C = np.zeros((Nmed, Nq))
138
+ Lbar = np.zeros((Nmed, Nq-1))
139
+ Cbar = np.zeros((Nmed, Nq))
133
140
 
134
- # Year starts at offset nm in the plan.
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 = nm + nn
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
- L[nn] = gamma_n[n] * irmaaBrackets[status][1:]
145
- C[nn] = imed * gamma_n[n] * irmaaFees
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 nm, L, C
156
+ return nmstart, Lbar, Cbar
150
157
 
151
158
 
152
- def capitalGainTaxRate(Ni, magi_n, gamma_n, nd, Nn):
159
+ def capitalGainTax(Ni, txIncome_n, ltcg_n, gamma_n, nd, Nn):
153
160
  """
154
- Return an array of decimal rates for capital gains.
155
- Parameter nd is the index year of first passing of a spouse, if applicable,
156
- nd == Nn for single individuals.
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
- cgRate_n = np.zeros(Nn)
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
- if magi_n[n] > gamma_n[n] * capGainRates[status][1]:
166
- cgRate_n[n] = 0.20
167
- elif magi_n[n] > gamma_n[n] * capGainRates[status][0]:
168
- cgRate_n[n] = 0.15
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 cgRate_n
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"Expiration year {yOBBBA} cannot be in the past.")
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
- Owl/timelists
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
- A retirement planner using linear programming optimization.
7
+ Copyright (C) 2025-2026 The Owlplanner Authors
7
8
 
8
- See companion document for a complete explanation and description
9
- of all variables and parameters.
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
- Utility functions to read and check timelists.
12
-
13
- Copyright &copy; 2024 - Martin-D. Lacasse
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 {finput}: {e}.") from e
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
- 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)
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
- df.loc[len(df)] = [year, 0, 0, 0, 0, 0, 0, 0, 0]
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]