- liquidation is now a pure function of state: the daily mark-to-market netting no longer consumes the carried loss pools or the open wash-sale candidates (unrealized gains/losses were leaking into the carryover pools, so equity[t] depended on past unrealized values) - loss offsetting is now global (chronological, by type) instead of per-symbol: a symbol A gain consuming a symbol B loss marks B's event consumed, so a later B repurchase cannot subtract the same loss a second time (pools could go negative -> over-taxed gains) - progressive mode: the reinvested after-tax remainder now uses the SAME marginal rates the tax is recorded at (was flat d_keep/c_keep, so recorded tax and cash flow diverged); the bracket position also advances by net short-term gains - renames: WASh->WASH, lt_rf/st_rf->lt_fed/st_fed (rf is the project's risk-free-rate convention); buys pruned to the 30d wash window; dropped the per-call sorted() on the constant bracket tables tests: wash-sale disallowance; liquidation purity (no-trade day with unrealized gains must not eat the pool); cross-symbol consumption (no double subtract). All scenarios verified to fail on the pre-fix code.
254 lines
14 KiB
Python
254 lines
14 KiB
Python
"""Unit tests for the after-tax model (tax.py) — synthetic, no data bundle.
|
|
|
|
Key regression: distributions are RAW per-share dollars, so the account
|
|
must hold RAW share units (valued on close prices). The old code valued
|
|
units on ADJ (total-return index) prices, which overstates every
|
|
distribution — and its tax — by the raw/adj ratio (e.g. a fund that has
|
|
paid many distributions: adj 11 vs raw 30 -> 2.7x overcharge).
|
|
|
|
Run: .venv/bin/python tests/test_tax.py
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pandas as pd
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
import tax # noqa: E402
|
|
|
|
PASS, FAIL = 0, 0
|
|
|
|
|
|
def check(name: str, ok: bool) -> None:
|
|
global PASS, FAIL
|
|
PASS += bool(ok)
|
|
FAIL += not ok
|
|
print((" ok " if ok else " FAIL ") + name)
|
|
|
|
|
|
def main() -> int:
|
|
# One fund, raw prices 30 -> 34, ex-div 5.00 on day 6 (34 -> 29),
|
|
# then 29.5. Account starts at 1.0 = 1/30 share.
|
|
dates = pd.to_datetime(["2024-01-0%d" % i for i in range(1, 8)])
|
|
close = pd.DataFrame({"f": [30.0, 31.0, 32.0, 33.0, 34.0, 29.0, 29.5]},
|
|
index=dates)
|
|
div = pd.DataFrame({"f": [0, 0, 0, 0, 0, 0.0, 0.0]}, index=dates)
|
|
capg = pd.DataFrame({"f": [0, 0, 0, 0, 0, 5.0, 0.0]}, index=dates)
|
|
|
|
r = tax.after_tax_portfolio(close, div, capg, {"f": 1.0}, lt_rate=0.20,
|
|
st_rate=0.15, div_rate=0.15)
|
|
t5 = dates[5]
|
|
|
|
# --- the regression: tax on the distribution is 20% of 5/30 of the
|
|
# account (the distribution as a fraction of the RAW price), i.e.
|
|
# 3.333% — not 5/adj (which the old code implied, ~4% with this
|
|
# adj path and up to 2.7x for high-history funds).
|
|
check("cap-gain tax is 20% of (5/30) of the account",
|
|
abs(r.taxes["capg_tax"][t5] - 0.2 * 5.0 / 30.0) < 1e-12)
|
|
check("no dividend tax", r.taxes["div_tax"].abs().sum() == 0.0)
|
|
|
|
# --- reinvestment: the after-tax remainder (5*0.8 of the 1/30 share)
|
|
# is bought at the ex-div close 29, so MV on the event day is
|
|
# (1/30)*(29 + 4) = 33/30. The tax is already inside the (smaller)
|
|
# units — it must NOT also show up in equity as a second deduction.
|
|
mv5 = r.equity[t5] + r.liq_tax[t5] # no cash component on this day
|
|
check("market value on ex-div day = 33/30 (after-tax reinvested)",
|
|
abs(mv5 - 33.0 / 30.0) < 1e-9)
|
|
|
|
# --- liquidation value: price drop (5/30) is exactly offset by the
|
|
# distribution kept, so equity falls by the true tax cost only.
|
|
# Day-5 equity = MV(33/30) - liq_tax(0: basis 1+4/30 > MV, a "loss"
|
|
# under the after-tax basis rule).
|
|
check("equity on ex-div day = 33/30 (tax cost lives in the units)",
|
|
abs(r.equity[t5] - 33.0 / 30.0) < 1e-9)
|
|
|
|
# --- pre-event day: plain ST-marked liquidation value.
|
|
t4 = dates[4]
|
|
check("equity day before = MV - 15% ST gain tax",
|
|
abs(r.equity[t4] - (34.0 / 30.0) * (1 - 0.15 * (4.0 / 34.0))) < 1e-9)
|
|
|
|
# --- cross-day consistency: equity(5)/equity(4) from the explicit
|
|
# values (day 4 = 34/30 MV less 15% ST gain tax; day 5 = 33/30).
|
|
check("ex-div equity step matches explicit values",
|
|
abs(r.equity[t5] / r.equity[t4]
|
|
- (33.0 / 30.0) / (34.0 / 30.0 - 0.15 * 4.0 / 30.0)) < 1e-9)
|
|
|
|
# --- no rebalancing: one lot survives, units grew by the reinvested
|
|
# net amount at the ex-div price.
|
|
check("single lot outstanding", r.lots_outstanding == 1)
|
|
|
|
# --- dividend leg taxed at div_rate, cap-gain leg at lt_rate:
|
|
# split a 1.00 distribution 0.4 div / 0.6 capg on day 6.
|
|
div2 = pd.DataFrame({"f": [0, 0, 0, 0, 0, 0.4, 0.0]}, index=dates)
|
|
capg2 = pd.DataFrame({"f": [0, 0, 0, 0, 0, 0.6, 0.0]}, index=dates)
|
|
r2 = tax.after_tax_portfolio(close, div2, capg2, {"f": 1.0}, lt_rate=0.20,
|
|
st_rate=0.15, div_rate=0.10)
|
|
check("split distribution taxed by component rate",
|
|
abs(r2.taxes["capg_tax"][t5] - 0.20 * 0.6 / 30.0) < 1e-12
|
|
and abs(r2.taxes["div_tax"][t5] - 0.10 * 0.4 / 30.0) < 1e-12)
|
|
|
|
# --- state/local + NIIT: composite rates add to each component
|
|
# (NY/NYC tax cap gains at ordinary rates — the caller passes the
|
|
# composite sl_rate; the model must not preferentiate cap gains).
|
|
r3 = tax.after_tax_portfolio(close, div2, capg2, {"f": 1.0}, lt_rate=0.20,
|
|
st_rate=0.15, div_rate=0.10, niit=0.038,
|
|
sl_rate=0.14)
|
|
check("capg dist taxed at lt + NIIT + state/local",
|
|
abs(r3.taxes["capg_tax"][t5] - (0.20 + 0.038 + 0.14) * 0.6 / 30.0) < 1e-12)
|
|
check("div taxed at div + NIIT + state/local",
|
|
abs(r3.taxes["div_tax"][t5] - (0.10 + 0.038 + 0.14) * 0.4 / 30.0) < 1e-12)
|
|
# realized-gain leg: two symbols, daily rebalance forces ST sells;
|
|
# every realized dollar is taxed at (st_rate + niit + sl_rate).
|
|
d3 = pd.to_datetime(["2024-01-0%d" % i for i in (1, 2, 3)])
|
|
close3 = pd.DataFrame({"a": [30.0, 34.0, 29.0], "b": [10.0, 10.0, 10.0]},
|
|
index=d3)
|
|
zero3 = pd.DataFrame(0.0, index=d3, columns=["a", "b"])
|
|
r4 = tax.after_tax_portfolio(close3, zero3, zero3, {"a": 0.5, "b": 0.5},
|
|
rebalance="1D", lt_rate=0.20, st_rate=0.15,
|
|
div_rate=0.10, niit=0.038, sl_rate=0.14)
|
|
real_tax = float(r4.taxes["realized_tax"].sum())
|
|
net_st = float(r4.realized["st_gain"].sum()) - \
|
|
float(r4.realized["st_loss"].sum())
|
|
check("realized ST gains/losses net-taxed at st + NIIT + state/local",
|
|
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)
|
|
|
|
# --- progressive federal brackets: with progressive=True the fed rate
|
|
# for each flow is the marginal rate at (fed_base - salt); flat mode
|
|
# ignores fed_base/salt and uses the flat rates.
|
|
r8 = tax.after_tax_portfolio(close, div2, capg2, {"f": 1.0}, lt_rate=0.20,
|
|
st_rate=0.15, div_rate=0.10, niit=0.038,
|
|
sl_rate=0.14, progressive=True, fed_base=500_000.0)
|
|
check("progressive: LTCG taxed at 15% marginal rate",
|
|
abs(r8.taxes["capg_tax"][t5] - (0.15 + 0.038 + 0.14) * 0.6 / 30.0) < 1e-12)
|
|
check("progressive: ordinary taxed at 32% marginal rate",
|
|
abs(r8.taxes["div_tax"][t5] - (0.32 + 0.038 + 0.14) * 0.4 / 30.0) < 1e-12)
|
|
r9 = tax.after_tax_portfolio(close, div2, capg2, {"f": 1.0}, lt_rate=0.20,
|
|
st_rate=0.15, div_rate=0.10, niit=0.038,
|
|
sl_rate=0.14, progressive=True, fed_base=800_000.0,
|
|
salt=10_000.0)
|
|
check("progressive: high income -> LTCG 20%, ordinary top rate 37%",
|
|
abs(r9.taxes["capg_tax"][t5] - (0.20 + 0.038 + 0.14) * 0.6 / 30.0) < 1e-12
|
|
and abs(r9.taxes["div_tax"][t5] - (0.37 + 0.038 + 0.14) * 0.4 / 30.0) < 1e-12)
|
|
r10 = tax.after_tax_portfolio(close, div2, capg2, {"f": 1.0}, lt_rate=0.20,
|
|
st_rate=0.15, div_rate=0.10, niit=0.038,
|
|
sl_rate=0.14, fed_base=1_000_000.0, salt=50_000.0)
|
|
check("flat mode ignores fed_base/salt (uses flat rates)",
|
|
abs(r10.taxes["capg_tax"][t5] - (0.20 + 0.038 + 0.14) * 0.6 / 30.0) < 1e-12
|
|
and abs(r10.taxes["div_tax"][t5] - (0.10 + 0.038 + 0.14) * 0.4 / 30.0) < 1e-12)
|
|
|
|
# --- loss carryover: a realized loss is carried in the loss pool and
|
|
# offsets a later realized gain; only the net is taxed.
|
|
d7 = pd.DatetimeIndex([pd.Timestamp("2024-01-02"), pd.Timestamp("2024-01-03"),
|
|
pd.Timestamp("2024-02-01")])
|
|
close7 = pd.DataFrame({"a": [30.0, 20.0, 20.0], "b": [40.0, 30.0, 45.0]}, index=d7)
|
|
zero7 = pd.DataFrame(0.0, index=d7, columns=["a", "b"])
|
|
r7c = tax.after_tax_portfolio(close7, zero7, zero7, {"a": 0.5, "b": 0.5}, rebalance="1D",
|
|
lt_rate=0.20, st_rate=0.15, div_rate=0.10, niit=0.0, sl_rate=0.0)
|
|
check("loss carryover: realized loss offsets later gain (net of losses)",
|
|
float(r7c.realized["st_loss"].sum()) > 0
|
|
and abs(float(r7c.taxes["realized_tax"][d7[2]])
|
|
- (float(r7c.realized["st_gain"][d7[2]])
|
|
- float(r7c.realized["st_loss"][d7[1]])) * 0.15) < 1e-9)
|
|
|
|
# --- wash sale: a loss sale of `a` on day 3, then `a` is repurchased
|
|
# on day 4 (within 30d) -> the carried loss is disallowed and the day-5
|
|
# gain is taxed in FULL (no offset).
|
|
dws = pd.to_datetime(["2024-01-0%d" % i for i in (1, 2, 3, 4, 5)] +
|
|
["2024-01-08"])
|
|
close_ws = pd.DataFrame({"a": [10.0, 10.0, 12.0, 9.0, 9.0, 12.0],
|
|
"b": [10.0, 5.0, 5.0, 4.0, 3.0, 3.0]}, index=dws)
|
|
zero_ws = pd.DataFrame(0.0, index=dws, columns=["a", "b"])
|
|
rws = tax.after_tax_portfolio(close_ws, zero_ws, zero_ws,
|
|
{"a": 0.5, "b": 0.5}, rebalance="1D",
|
|
lt_rate=0.20, st_rate=0.15, div_rate=0.10)
|
|
check("wash sale: repurchase within 30d disallows the carried loss",
|
|
float(rws.realized["st_loss"][dws[3]]) > 0
|
|
and float(rws.realized["st_gain"][dws[5]]) > 0
|
|
and abs(float(rws.taxes["realized_tax"][dws[5]])
|
|
- float(rws.realized["st_gain"][dws[5]]) * 0.15) < 1e-9)
|
|
|
|
# --- liquidation is pure: day 2 realizes an ST loss into the pool;
|
|
# day 3 is a NO-TRADE day (all symbols up 1.3x proportionally) with big
|
|
# UNREALIZED gains - the liquidation math must not consume the pool;
|
|
# day 4's realized gain of `a` is still offset exactly by the carried
|
|
# loss (old code under-taxed it: the pool had been eaten by the marks).
|
|
dlp = pd.to_datetime(["2024-01-0%d" % i for i in (1, 2, 3, 4)])
|
|
clp = pd.DataFrame({"a": [10.0, 5.0, 6.5, 14.0],
|
|
"b": [10.0, 8.0, 10.4, 10.4],
|
|
"c": [10.0, 10.0, 13.0, 13.0]}, index=dlp)
|
|
zero_lp = pd.DataFrame(0.0, index=dlp, columns=["a", "b", "c"])
|
|
rlp = tax.after_tax_portfolio(clp, zero_lp, zero_lp,
|
|
{"a": 1 / 3, "b": 1 / 3, "c": 1 / 3},
|
|
rebalance="1D", lt_rate=0.20, st_rate=0.15,
|
|
div_rate=0.10)
|
|
check("liquidation is pure: unrealized gains do not consume the pool",
|
|
float(rlp.realized["st_loss"][dlp[1]]) > 0
|
|
and float(rlp.realized["st_gain"][dlp[3]]) > 0
|
|
and abs(float(rlp.taxes["realized_tax"][dlp[3]])
|
|
- (float(rlp.realized["st_gain"][dlp[3]])
|
|
- float(rlp.realized["st_loss"][dlp[1]])) * 0.15) < 1e-9)
|
|
|
|
# --- global loss offset vs per-symbol wash bookkeeping: b's loss (day 2)
|
|
# is offset by a's gain (day 3, `a` is processed first) -> b's loss
|
|
# event is consumed, so the b repurchase on day 3 must NOT subtract the
|
|
# loss a second time. Day 4's b gain is therefore taxed in full
|
|
# (no phantom extra offset from a negative pool).
|
|
dcs = pd.to_datetime(["2024-01-0%d" % i for i in (1, 2, 3, 4)])
|
|
ccs = pd.DataFrame({"a": [10.0, 6.0, 15.0, 10.0],
|
|
"b": [10.0, 8.0, 6.0, 12.0],
|
|
"c": [10.0, 6.0, 6.0, 10.0]}, index=dcs)
|
|
zero_cs = pd.DataFrame(0.0, index=dcs, columns=["a", "b", "c"])
|
|
rcs = tax.after_tax_portfolio(ccs, zero_cs, zero_cs,
|
|
{"a": 1 / 3, "b": 1 / 3, "c": 1 / 3},
|
|
rebalance="1D", lt_rate=0.20, st_rate=0.15,
|
|
div_rate=0.10)
|
|
check("cross-symbol offset marks the loss consumed (no double subtract)",
|
|
float(rcs.realized["st_loss"][dcs[1]]) > 0
|
|
and float(rcs.realized["st_gain"][dcs[3]]) > 0
|
|
and abs(float(rcs.taxes["realized_tax"][dcs[3]])
|
|
- float(rcs.realized["st_gain"][dcs[3]]) * 0.15) < 1e-9)
|
|
|
|
print(f"\n{PASS} passed, {FAIL} failed")
|
|
return 1 if FAIL else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|