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.
This commit is contained in:
Greg Pomerantz 2026-08-27 14:34:42 -04:00
parent 8a9ca25750
commit 9458e316cb
6 changed files with 3109 additions and 11 deletions

26
app.py
View File

@ -911,20 +911,37 @@ with tab_fundlab:
"dividends + LTCG + tax-exempt). MIXED funds: pull the "
"last 1099-DIV - it is the final arbiter.")
_sp = {}
_SPF = _dc.RESULTS.parent / "taxsplit_results.json"
if _SPF.exists():
_spj = json.loads(_SPF.read_text())
for _grp in _spj.values():
for _s, _v in _grp.items():
if _v:
_sp.setdefault(_s.upper(), _v)
def _tp_table(funds: dict) -> pd.DataFrame:
rows = []
for s, r in sorted(funds.items()):
_v = _sp.get(s.upper())
rows.append({
"fund": s,
"name": r["name"][:44],
"location": r["location"],
"5y price": (f"{_v['price']*100:+.0f}%"
if _v else ""),
"5y payout": (f"{_v['payout_a']*100:.1f}%/yr"
if _v else ""),
"12m payout": (f"{_v['payout_12m']*100:.1f}%"
if _v else ""),
"score": r["score"],
"basis": r["basis"],
"notes": r["notes"][:120],
})
order = {"TAXABLE": 0, "TAXABLE (munis)": 1,
"MIXED (check 1099)": 2, "IRA": 3,
"FLEXIBLE (cash)": 4, "NO DATA": 9}
order = {"TAXABLE": 0, "TAXABLE (accrues)": 1,
"TAXABLE (munis)": 2,
"MIXED (check 1099)": 3, "IRA": 4,
"FLEXIBLE (cash)": 5, "NO DATA": 9}
df = pd.DataFrame(rows)
df["_o"] = df["location"].map(
lambda x: order.get(x, 5))
@ -938,7 +955,8 @@ with tab_fundlab:
st.dataframe(_tp_table(_tp["xcheck"]), width="stretch")
_sel = st.selectbox(
"Candidates (250)",
["All locations", "TAXABLE", "TAXABLE (munis)",
["All locations", "TAXABLE", "TAXABLE (accrues)",
"TAXABLE (munis)",
"MIXED (check 1099)", "IRA", "FLEXIBLE (cash)"],
key="_tp_loc")
_c = _tp["candidates"]

View File

@ -130,6 +130,46 @@ not a gate.
4. CEF universe (485/N-2 filers) - separate pass; CEFs have
premium/discount dynamics the NAV screen can't see.
### Price-appreciation vs. payout split (fundlab/taxsplit.py, 2026-08-27)
Follow-up to the tax-location plan: the user pointed out that fund
NAV appreciation is ALSO a capital gain (LTCG on a >1y sale) - the
taxplan score only measured DISTRIBUTION character. 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:
price appreciation = f(Close) -> investor's own gain on sale
payout component = f(Adj)-f(Close) -> taxed every year
FINDINGS (5y windows):
- ACCUMULATORS (>=50% of return is appreciation -> upgraded to
"TAXABLE (accrues)" in taxplan): MBXIX 76% (0.0% payout 12m),
ATESX 66%, LAMHX 62%, CVSIX 61%, and candidate PBAIX 60%
(0.0% payout 12m). For these the taxable account's LTCG-on-sale
benefit is real and large - the annual drag is tiny.
- PAY-OUT funds (return comes almost entirely as distributions):
HMEZX (99% paid out - the STCG merger-arb case), MERVX, COSIX,
PMORX, SVARX, SCFZX, DMSZX, all munis, EM/HY credit. IRA stands.
- DATA ARTIFACTS caught: JLPSX & QSPNX show one-time NAV gap events
~2022 (special distribution or share-class reorg) - the 5y payout
average (13%/yr) is misleading; the 12m payout column (0-3%)
reflects current behavior. QCMMRX (MMF) price data is a
total-return index, not NAV - its "100% appreciation" is
meaningless. Hence both the 5y and the 12m payout are shown.
CAPITAL-LOSS DISTRIBUTIONS: registered US RICs CANNOT distribute
net realized capital losses to shareholders (code) - losses stay in
the fund as a carryforward. The taxable-account benefit the user was
looking for exists as the fund's HARVEST RESERVE: a fund that
realized losses in down years (2022/2025) offsets FUTURE gain
distributions, so later appreciation converts to distributions
without triggering a gain distribution. Detectable only from the
annual report (N-CSR) / 1099 history: low-or-zero capital-gain
distributions after strong up-years. K-1 vehicles (MLPs, some
private credit) DO pass through losses, but they're ordinary-income
vehicles -> IRA anyway. NEXT: pull N-CSR per-share distribution
tables for the MIXED funds (PMAIX/PMFKX, QSPNX, MBXIX, EGRIX,
DMSZX, PBAIX, FCVSX/FISCX, MMUFX/PRUAX, PABAX, WISEX).
### Tax-location plan (fundlab/taxplan.py, 2026-08-27)
User's premise: current LTCG rate < future ordinary rate, so a fund's
account placement follows the CHARACTER of its distributions:

View File

@ -275,7 +275,38 @@ MANUAL: dict[str, tuple[str, str]] = {
}
def finalize(sym: str, r: dict) -> dict:
SPLIT = HERE / "taxsplit_results.json"
APPR_SHARE_MIN = 0.50 # >= half of the 5y return is price appreciation
def _splits() -> dict:
"""flattened taxsplit results: sym -> split dict (all groups)."""
if not SPLIT.exists():
return {}
d = json.loads(SPLIT.read_text())
out: dict = {}
for grp in d.values():
for s, v in grp.items():
if v:
out.setdefault(s.upper(), v)
return out
def finalize(sym: str, r: dict, splits: dict | None = None) -> dict:
s = (splits or _splits()).get(sym.upper())
# an ACCUMULATOR: most of its return is price appreciation, realized
# as the INVESTOR'S own LTCG on a >1y sale (the distributions are
# small, so the annual ordinary/STCG drag is small too). That is a
# taxable-account profile even when the distribution character is
# murky.
if (s and s.get("appr_share") is not None
and s["appr_share"] >= APPR_SHARE_MIN
and r["location"] in ("IRA", "MIXED (check 1099)")):
r["location"] = "TAXABLE (accrues)"
r["notes"] = (f"{s['appr_share']*100:.0f}% of 5y return is price "
f"appreciation (only {s['payout_12m']*100:.1f}% "
f"payout in the last 12m) - the gain is YOURS on a "
">1y sale, at the LTCG rate. " + r["notes"]).strip(" ;")
if sym.upper() in MANUAL:
loc, note = MANUAL[sym.upper()]
r["location"] = loc
@ -287,6 +318,11 @@ def finalize(sym: str, r: dict) -> dict:
# ------------------------------------------------------------------------
def run() -> dict:
# the appreciation split must exist before finalize() can use it
if not SPLIT.exists():
from fundlab import taxsplit
taxsplit.run()
# sleeve loadings as a fallback for groups whose N-PORT parse left
# the book mostly unclassified. The 16-fund shortlist was never in
# the 2,384 screen, so compute + cache betas for those on demand.

View File

@ -23,8 +23,8 @@
"basis": "sleeves",
"score": 0.35,
"unknown": 0.0,
"location": "IRA",
"notes": "market-neutral: gains from short-dated option/systematic trades - often STCG",
"location": "TAXABLE (accrues)",
"notes": "61% of 5y return is price appreciation (only 1.5% payout in the last 12m) - the gain is YOURS on a >1y sale, at the LTCG rate. market-neutral: gains from short-dated option/systematic trades - often STCG",
"as_of": "January 31, 2026"
},
"JLPSX": {
@ -86,8 +86,8 @@
"basis": "N-PORT+sleeves",
"score": 0.5,
"unknown": 1.0,
"location": "MIXED (check 1099)",
"notes": "unclassified: Fund holdings 77%, US govt 23%; holdings mostly unclassified - used return sleeves; hedge fund: gains often short-term - check 1099",
"location": "TAXABLE (accrues)",
"notes": "76% of 5y return is price appreciation (only 0.0% payout in the last 12m) - the gain is YOURS on a >1y sale, at the LTCG rate. unclassified: Fund holdings 77%, US govt 23%; holdings mostly unclassified - used return sleeves; hedge fund: gains often short-term - check 1099",
"as_of": "September 30, 2024"
},
"EAGMX": {
@ -1791,8 +1791,8 @@
"basis": "sleeves",
"score": 0.23,
"unknown": 0.0,
"location": "IRA",
"notes": ""
"location": "TAXABLE (accrues)",
"notes": "60% of 5y return is price appreciation (only 0.0% payout in the last 12m) - the gain is YOURS on a >1y sale, at the LTCG rate."
},
"PBCAX": {
"name": "PGIM CALIFORNIA MUNI INCOME FUND",

136
fundlab/taxsplit.py Normal file
View File

@ -0,0 +1,136 @@
"""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()

File diff suppressed because it is too large Load Diff