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:
parent
5e5a10d725
commit
9f657dc9f4
9
tax.py
9
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 = 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]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user