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 = 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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user