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).
This commit is contained in:
Greg Pomerantz 2026-09-01 17:22:04 -04:00
parent 5e5a10d725
commit 9f657dc9f4
2 changed files with 41 additions and 2 deletions

9
tax.py
View File

@ -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 = np.array([weights[s] for s in syms])
w0 = w0 / w0.sum() w0 = w0 / w0.sum()
cost = cost_bps / 1e4 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) # state (account = 1.0 at start)
units = w0 / pv[0] 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, 0] = d_tax
tax_rows[t, 1] = c_tax tax_rows[t, 1] = c_tax
for i in range(len(syms)): 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: if net <= 0:
continue continue
px_i = float(prices[i]) px_i = float(prices[i])
@ -217,7 +221,8 @@ def after_tax_portfolio(close: pd.DataFrame, div: pd.DataFrame, capg: pd.DataFra
else: else:
st_loss += -gain st_loss += -gain
liq_tax[t] = _liquidation_tax(lt_gain, lt_loss, st_gain, st_loss, 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] equity[t] = market_value + cash - liq_tax[t]
tax_rows[t, 3] = tax_rows[t, 0] + tax_rows[t, 1] + tax_rows[t, 2] tax_rows[t, 3] = tax_rows[t, 0] + tax_rows[t, 1] + tax_rows[t, 2]

View File

@ -115,6 +115,40 @@ def main() -> int:
float(r4.realized["st_gain"].sum()) > 0 and float(r4.realized["st_gain"].sum()) > 0 and
abs(real_tax - net_st * (0.15 + 0.038 + 0.14)) < 1e-9) 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") print(f"\n{PASS} passed, {FAIL} failed")
return 1 if FAIL else 0 return 1 if FAIL else 0