f/fundlab/taxsplit.py
Greg Pomerantz 9458e316cb Price-appreciation vs payout split (fundlab/taxsplit.py)
Follow-up to the tax-location plan: the taxplan score only measured
DISTRIBUTION character. The user rightly noted that NAV appreciation
is also a capital gain (LTCG on a >1y sale). The fund price files
carry both series - Close = raw NAV with distributions paid out,
Adj Close = total return reinvested - so the split is computable
directly per fund (5y window + most-recent-12m payout).

Findings:
- ACCUMULATORS (>=50% of 5y return is price appreciation) get a new
  location "TAXABLE (accrues)": MBXIX 76% (0% payout 12m), ATESX 66%,
  LAMHX 62%, CVSIX 61%, candidate PBAIX 60% (0% payout 12m). For
  these the taxable account's LTCG-on-sale benefit is the dominant
  tax event.
- PAY-OUT funds: HMEZX (99% of return distributed - the STCG merger-
  arb case), MERVX, COSIX, PMORX, SVARX, SCFZX, DMSZX, munis, credit.
  IRA placement stands.
- Data artifacts caught: JLPSX/QSPNX one-time NAV gap events ~2022
  (special distribution or reorg) skew the 5y payout average; the
  12m payout column reflects current behavior. QCMMRX (MMF) series
  is not NAV-based - flagged.

App: tax-location expander gains 5y price / 5y payout / 12m payout
columns and the "TAXABLE (accrues)" filter. RESEARCH.md documents
the capital-loss question: registered RICs cannot distribute net
capital losses; the usable benefit is the fund's internal harvest
reserve (low capital-gain distributions after up-years), which needs
N-CSR/1099 history to verify. 97/32 suites green.
2026-08-27 14:34:42 -04:00

137 lines
5.1 KiB
Python

"""Price appreciation vs. payout split, for tax placement.
The fund price files carry two series:
Close = raw NAV with distributions PAID OUT (price appreciation)
Adj Close = total return with distributions REINVESTED
So for each fund we can decompose its total return into:
price appreciation (realized as LTCG when you SELL the shares)
payout component (taxed every year when it is DISTRIBUTED)
That is exactly the trade-off behind the taxable-vs-IRA placement:
the payout is the recurring tax drag in a taxable account, the
appreciation is deferred (LTCG if held >1y). A fund whose returns
come mostly as payouts is the one that hurts in a taxable account;
a fund that accumulates (appreciates) defers the tax.
Caveats:
- "payout component" includes dividends, interest AND capital-gain
distributions; it does not tell you their character (that needs the
1099-DIV).
- arithmetic annualization: dist_a = tr_a - pr_a is an approximation
(fine while yields are <~15%/yr).
- NAV gaps: funds with <250 observations in the window are skipped.
Run: python -m fundlab.taxsplit
Output: fundlab/taxsplit_results.json
"""
from __future__ import annotations
import json
from pathlib import Path
import pandas as pd
HERE = Path(__file__).parent
RESULTS = HERE / "taxsplit_results.json"
STOCKS = Path.home() / "prog" / "fin" / "stocks"
W_5Y = "2021-01-01"
def fund_split(sym: str, start: str | None) -> dict | None:
"""(price- and total-return-based split) over a window."""
p = STOCKS / f"{sym.lower()}-history.csv"
if not p.exists():
return None
d = pd.read_csv(p, parse_dates=["Date"])
if start:
d = d[d["Date"] >= start]
d = d.dropna(subset=["Close", "Adj Close"]).sort_values("Date")
if len(d) < 250:
return None
yrs = (d["Date"].iloc[-1] - d["Date"].iloc[0]).days / 365.25
pr = float(d["Close"].iloc[-1] / d["Close"].iloc[0] - 1)
tr = float(d["Adj Close"].iloc[-1] / d["Adj Close"].iloc[0] - 1)
pr_a = (1 + pr) ** (1 / yrs) - 1
tr_a = (1 + tr) ** (1 / yrs) - 1
dist_a = tr_a - pr_a # annualized payout, approx
# share of the ANNUALIZED total return that is price appreciation
appr_share = pr_a / tr_a if tr_a > 0.005 else None
# most-recent-12m payout: the CURRENT distribution behavior is what
# matters for placement - the 5y average can be skewed by one-time
# events (special distributions, share-class reorganizations)
d12 = d[d["Date"] >= d["Date"].iloc[-1] - pd.DateOffset(months=12)]
p12 = float(d12["Close"].iloc[-1] / d12["Close"].iloc[0] - 1)
t12 = float(d12["Adj Close"].iloc[-1] / d12["Adj Close"].iloc[0] - 1)
payout_12m = t12 - p12
return {"yrs": round(yrs, 2),
"tot": round(tr, 4), "price": round(pr, 4),
"tot_a": round(tr_a, 4), "price_a": round(pr_a, 4),
"payout_a": round(dist_a, 4),
"payout_12m": round(payout_12m, 4),
"appr_share": (round(appr_share, 3) if appr_share is not None
else None)}
def _groups() -> dict[str, list[str]]:
"""The three fund groups, from the primary sources (not from
taxplan's output - taxplan depends on THIS file)."""
dr = json.loads((HERE / "decompose_results.json").read_text())
xc = json.loads((HERE / "xcheck_report.json").read_text())
fac = json.loads((HERE / "factor_results.json").read_text())
return {
"shortlist": sorted(s.upper() for s in dr),
"xcheck": sorted(s.upper() for s in xc),
"candidates": sorted(s.upper() for s, v in fac.items()
if (v.get("verdict") or "").startswith(
"CANDIDATE")),
}
def run() -> dict:
out: dict = {}
for grp, syms in _groups().items():
out[grp] = {}
for s in syms:
r5 = fund_split(s, W_5Y)
if r5:
out[grp][s] = r5
RESULTS.write_text(json.dumps(out, indent=1))
_print(out)
return out
def _print(out: dict) -> None:
def fmt(r: dict | None) -> str:
if r is None:
return " (no data)"
sh = " - " if r["appr_share"] is None else f"{r['appr_share']*100:3.0f}%"
return (f"5y tot {r['tot']*100:+7.1f}% price {r['price']*100:+7.1f}% "
f"payout {r['payout_a']*100:4.1f}%/yr appr-share {sh}")
for grp in ("shortlist", "xcheck"):
print(f"\n== {grp} ==")
for s in sorted(out[grp]):
print(f" {s:<7} {fmt(out[grp][s])}")
c = out["candidates"]
have = [s for s in c if c[s] is not None]
# rank by appreciation share: who ACCUMULATES vs who PAYS OUT
ranked = sorted((s for s in have),
key=lambda s: -(c[s]["appr_share"] or -1))
print(f"\n== candidates: most appreciation (accumulate) vs "
f"most payout (pay out) ==")
print(" top 15 by appreciation share of total return:")
for s in ranked[:15]:
print(f" {s:<7} {fmt(c[s])}")
print(" bottom 15 (return comes almost entirely as payouts):")
for s in ranked[-15:]:
print(f" {s:<7} {fmt(c[s])}")
if __name__ == "__main__":
run()