The old model valued holdings in ADJ (total-return index) units but computed distribution flows as raw per-share dollars — so every distribution, and its tax, was overstated by the raw/adj ratio (JLPSX: 30.10/11.25 = 2.7x; the Dec-2020 cap-gain tax showed as 12.1% of the account instead of the true 4.5%). The wiggle in the after-tax curve was this bug, not a convention issue. tax.py now: - values holdings in RAW share units on close prices (bundle.close); - receives the per-share distribution on its event date, pays the tax (recorded in TaxResult.taxes), and reinvests the after-tax remainder at the same day's raw close — the tax's effect lives in the (smaller) reinvested units and is NOT also deducted from cash (double-count caught and fixed in review); - recomputes market value after the reinvestment so equity[t] is the post-event liquidation value. With the fix, the 'as-if-liquidated' equity on JLPSX's ex-div day drops by exactly the true tax cost (4.72% vs 12.1% before); the -22.9% price drop is offset by the distribution kept. Also: - app.py passes bundle.close to the after-tax model (pre-tax portfolio_returns still uses adj); - JLPSX/JLPYX: the 2020-12-11 6.824 capital-gain distribution is moved to the true ex-div date 2020-12-14 (remove/add correction ops), so the reinvestment prices at the post-drop close; - tests/test_tax.py: 8 synthetic regression tests (tax magnitude, reinvestment MV, no double-count, ex-div equity step, per-component rates); run_tests.sh now runs it.
98 lines
4.1 KiB
Python
98 lines
4.1 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)
|
|
|
|
print(f"\n{PASS} passed, {FAIL} failed")
|
|
return 1 if FAIL else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|