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