From 9f657dc9f41de440fabcd77b8bbaa8b9cdd7cbae Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Tue, 1 Sep 2026 17:22:04 -0400 Subject: [PATCH] Tax engine: apply NIIT + state/local to the equity path (not just recorded taxes) The full rate stack (federal + NIIT + state) was used for the recorded tax columns and for realized gains at rebalances, but the equity path itself deducted only the FEDERAL rate in two places: 1. distribution reinvestment kept (1 - div_rate)/(1 - lt_rate) instead of (1 - div_rate - niit - sl_rate); 2. the per-date liquidation tax ('sell everything today') passed bare (lt_rate, st_rate) to _liquidation_tax. Symptom: FLCSX 10Y showed a 1.4pt after-tax drag instead of the true ~2.8pt. Fix: d_keep/c_keep factors and stacked liquidation rates. Regression tests pin the equity path: reinvested net with NIIT+state, ST and LT liquidation tax at the full stack (all fail on the old code). Corrected 2015-2026 NYC after-tax: SPY 13.82->11.23, IVV 13.81->11.22, JLPSX 13.68->9.18 (previously reported 12.51/12.50/11.31, superseded). --- tax.py | 9 +++++++-- tests/test_tax.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/tax.py b/tax.py index 144f76e..5fcf35a 100644 --- a/tax.py +++ b/tax.py @@ -115,6 +115,10 @@ def after_tax_portfolio(close: pd.DataFrame, div: pd.DataFrame, capg: pd.DataFra w0 = np.array([weights[s] for s in syms]) w0 = w0 / w0.sum() cost = cost_bps / 1e4 + # full NYC-style stacks: distributions keep (1 - federal - NIIT - state); + # a sale (liquidation) gains are taxed at (federal + NIIT + state) + d_keep = max(0.0, 1.0 - div_rate - niit - sl_rate) + c_keep = max(0.0, 1.0 - lt_rate - niit - sl_rate) # state (account = 1.0 at start) units = w0 / pv[0] @@ -147,7 +151,7 @@ def after_tax_portfolio(close: pd.DataFrame, div: pd.DataFrame, capg: pd.DataFra tax_rows[t, 0] = d_tax tax_rows[t, 1] = c_tax for i in range(len(syms)): - net = (dv[t, i] * (1 - div_rate) + cv[t, i] * (1 - lt_rate)) * units[i] + net = (dv[t, i] * d_keep + cv[t, i] * c_keep) * units[i] if net <= 0: continue px_i = float(prices[i]) @@ -217,7 +221,8 @@ def after_tax_portfolio(close: pd.DataFrame, div: pd.DataFrame, capg: pd.DataFra else: st_loss += -gain liq_tax[t] = _liquidation_tax(lt_gain, lt_loss, st_gain, st_loss, - lt_rate, st_rate) + lt_rate + niit + sl_rate, + st_rate + niit + sl_rate) equity[t] = market_value + cash - liq_tax[t] tax_rows[t, 3] = tax_rows[t, 0] + tax_rows[t, 1] + tax_rows[t, 2] diff --git a/tests/test_tax.py b/tests/test_tax.py index e89d2ed..c75d90c 100644 --- a/tests/test_tax.py +++ b/tests/test_tax.py @@ -115,6 +115,40 @@ def main() -> int: float(r4.realized["st_gain"].sum()) > 0 and abs(real_tax - net_st * (0.15 + 0.038 + 0.14)) < 1e-9) + # --- EQUITY PATH (not just recorded taxes) with the full stack: + # the reinvested net must deduct (federal + NIIT + state), and the + # liquidation tax on each date must use the same stacked rates. + # A: cap-gain dist 5.0 on a 30-priced fund; keep = 1-0.20-0.038-0.14 + zero_d = pd.DataFrame(0.0, index=dates, columns=["f"]) + r5 = tax.after_tax_portfolio(close, zero_d, capg, {"f": 1.0}, + lt_rate=0.20, st_rate=0.15, div_rate=0.10, + niit=0.038, sl_rate=0.14) + keep = 1.0 - 0.20 - 0.038 - 0.14 + mv5 = float(r5.equity[t5]) + float(r5.liq_tax[t5]) + check("reinvested net deducts NIIT + state (MV = 29/30 + net/30)", + abs(mv5 - (29.0 / 30.0 + (5.0 / 30.0) * keep)) < 1e-9) + # B: sale tax on each calculation date — ST window (2 days) + dlt = pd.to_datetime(["2024-01-01", "2024-01-02"]) + c2 = pd.DataFrame({"f": [30.0, 34.0]}, index=dlt) + r6 = tax.after_tax_portfolio(c2, pd.DataFrame(0.0, index=dlt, columns=["f"]), + pd.DataFrame(0.0, index=dlt, columns=["f"]), + {"f": 1.0}, lt_rate=0.20, st_rate=0.15, + div_rate=0.10, niit=0.038, sl_rate=0.14) + check("liquidation tax (ST) uses st + NIIT + state", + abs(float(r6.equity[dlt[1]]) + - (34.0 / 30.0 - (0.15 + 0.038 + 0.14) * 4.0 / 30.0)) < 1e-9) + # B2: ...and the LT leg + dlt2 = pd.to_datetime(["2022-01-03", "2024-01-02"]) + c2b = pd.DataFrame({"f": [30.0, 34.0]}, index=dlt2) + r7 = tax.after_tax_portfolio(c2b, pd.DataFrame(0.0, index=dlt2, + columns=["f"]), + pd.DataFrame(0.0, index=dlt2, columns=["f"]), + {"f": 1.0}, lt_rate=0.20, st_rate=0.15, + div_rate=0.10, niit=0.038, sl_rate=0.14) + check("liquidation tax (LT) uses lt + NIIT + state", + abs(float(r7.equity[dlt2[1]]) + - (34.0 / 30.0 - (0.20 + 0.038 + 0.14) * 4.0 / 30.0)) < 1e-9) + print(f"\n{PASS} passed, {FAIL} failed") return 1 if FAIL else 0