f/fundlab/taxplan.py
Greg Pomerantz 895efc9bff Correct ROC placement: ROC defers to the investor's LTCG, like
appreciation - a taxable-account feature, not an IRA one

Correction after user pushback. The earlier note "ROC does not help
the taxable case - the deferral replicates the IRA" was WRONG: a
traditional IRA defers to the ORDINARY rate at withdrawal, while ROC
in a taxable account defers to the LTCG rate on a >1y sale (the
distribution is basis-reducing and reappears inside the shareholder's
own capital gain). Under the premise LTCG rate < future ordinary
rate, ROC - like NAV appreciation - favors the taxable account.
Contrast ordinary income (interest, ordinary divs, STCG): taxed at
the ordinary rate in BOTH accounts, so only the IRA's deferral wins.

taxplan.py:
- _deferred_share(): per fund, share of 5y total return that defers
  to the investor = (NAV change + ROC) / total return, from the
  parsed per-share N-CSR table, max'd with the taxsplit appreciation
  share.
- >= 50% deferred -> location "TAXABLE (defers to LTCG)" (renamed
  from "TAXABLE (accrues)"); 10-50% ROC in distributions -> note.
- Merger-arb cap 0.35 -> 0.50: HMEZX's per-share table (52% NII /
  30% gains / 18% ROC over 5y) refutes "mostly STCG" - HMEZX/MERVX
  are now MIXED (check 1099), not clean IRA.
- RESEARCH.md: corrected placement write-up, incl. the distinction
  between the tax question and the fund-quality question (heavy ROC
  can mean principal erosion - PGSIX NAV -34%/5y - which affects
  selection, not the optimal account).

App: order dict + filter updated to the new location name.
Tests: merger-arb expectation updated to MIXED; 2 new checks for
the ROC upgrade/note logic. 99/99 fundlab + 32/32 app.
2026-08-27 17:11:55 -04:00

479 lines
19 KiB
Python

"""Tax-location plan: which funds belong in the TAXABLE account vs an IRA.
The user's premise: current LTCG rate < ordinary-income rate expected
after retirement. So a fund's right home depends on the CHARACTER of
its distributions:
qualified dividends + LTCG -> TAXABLE (the LTCG rate is the benefit)
tax-exempt interest (munis) -> TAXABLE (wasted in an IRA)
ordinary interest / STCG /
non-qualified (REIT, K-1) -> IRA (deferring it is the benefit)
cash -> FLEXIBLE (no character to place)
We don't have 1099-DIV characterizations on file for 2,400 funds, so
this is a STRUCTURAL estimate from what the fund actually holds:
1. 16-fund shortlist -> nport_cache/*.json buckets (keyword-based)
2. 22 cross-checked -> xcheck_report.json buckets (SEC
assetCat/issuerCat codes, precise)
3. 250 candidates -> factor_results.json sleeve loadings (proxy:
a fund that trades like 90% Treasuries earns
~90% ordinary income)
plus name-based strategy overrides (merger arb gains are mostly
SHORT-TERM, munis are tax-exempt, FOFs are pass-through, ...).
The output says where a fund's income character points; the 1099-DIV
for the most recent year is the final arbiter for MIXED funds.
Run: python -m fundlab.taxplan
Output: fundlab/taxplan_results.json
"""
from __future__ import annotations
import json
import re
from pathlib import Path
HERE = Path(__file__).parent
EXTRA_BETAS = HERE / "universe_cache" / "betas_extra.json"
SHORTLIST = HERE / "decompose_results.json"
XCHECK = HERE / "xcheck_report.json"
FACTORS = HERE / "factor_results.json"
RESULTS = HERE / "taxplan_results.json"
# ------------------------------------------------------------------------
# bucket -> (fraction of distributions that are tax-favorable, character)
# tax-favorable = qualified dividends + LTCG + tax-exempt interest.
# A None fraction means "pass-through / unclassified - unknown".
# ------------------------------------------------------------------------
BUCKET_FRAC: dict[str, tuple[float | None, str]] = {
# equity: qualified dividends + LTCG
"Equity (US)": (1.0, "qualified div + LTCG"),
"Equity (intl)": (1.0, "qualified div + LTCG"),
"Equity (common)": (1.0, "qualified div + LTCG"),
"EC": (1.0, "qualified div + LTCG"),
# tax-exempt
"Municipal bond": (1.0, "tax-exempt interest"),
# mostly non-qualified
"Real estate": (0.15, "REIT income (mostly ordinary)"),
"RE": (0.15, "REIT income (mostly ordinary)"),
"Preferred stock": (0.15, "preferred div (mostly ordinary)"),
"EP": (0.15, "preferred div (mostly ordinary)"),
# commodities: 60/40 LTCG if section 1256 futures
"Commodities": (0.60, "commodity (60/40 if 1256)"),
"Commodity": (0.60, "commodity (60/40 if 1256)"),
"COMM": (0.60, "commodity (60/40 if 1256)"),
# derivatives / structured
"Futures": (0.40, "derivatives (mixed)"),
"Derivative / hedge": (0.40, "derivatives (mixed)"),
"Structured note": (0.30, "structured (mixed)"),
"SN": (0.30, "structured (mixed)"),
# debt: ordinary interest
"US Treasury": (0.0, "ordinary interest"),
"US agency": (0.0, "ordinary interest"),
"US GSE (Fed/FF)": (0.0, "ordinary interest"),
"Corporate bond": (0.05, "ordinary interest"),
"Debt": (0.05, "ordinary interest"),
"Foreign sovereign": (0.0, "ordinary interest"),
"High yield": (0.0, "ordinary interest"),
"Loan (leveraged/private credit)": (0.0, "ordinary / K-1"),
"LON": (0.0, "ordinary / K-1"),
"CLO (collateralized debt)": (0.0, "ordinary interest"),
"ABS-CBDO": (0.0, "ordinary interest"),
"ABS-O": (0.0, "ordinary interest"),
"ABS-MBS": (0.0, "ordinary interest"),
"ABS-APCP": (0.0, "ordinary interest"),
"ABS other (CLO/CMBS/AB)": (0.0, "ordinary interest"),
"ABS auto/personal loan": (0.0, "ordinary interest"),
"MBS (mortgage-backed)": (0.0, "ordinary interest"),
"Agency MBS": (0.0, "ordinary interest"),
"DBT": (0.05, "debt (ordinary)"),
# cash: ordinary, no placement value
"Cash & T-bills": (0.0, "cash (ordinary)"),
"Cash/MMF (short-term)": (0.0, "cash (ordinary)"),
"STIV": (0.0, "cash (ordinary)"),
"Repurchase agreement": (0.0, "ordinary interest"),
"RA": (0.0, "ordinary interest"),
# pass-through / unclassified
"Fund holdings": (None, "FOF - pass-through"),
"Fund/ETF holdings": (None, "FOF - pass-through"),
"PF": (None, "private fund - pass-through"),
"RF": (None, "fund - pass-through"),
# nport.py lump: corporates + munis together
"IG credit / munis": (0.45, "IG credit + munis (mixed)"),
"Other": (None, "unclassified"),
}
# ------------------------------------------------------------------------
# sleeve -> same fraction (proxy for the candidate set, no N-PORT on
# file). Equity sleeves = qualified div + LTCG; bond sleeves = ordinary;
# commodity/CTA sleeves = 60/40 if section 1256.
# ------------------------------------------------------------------------
SLEEVE_FRAC: dict[str, float] = {
"qqq": 1.0, "ivv": 1.0, "iwm": 1.0, "vea": 1.0, "efa": 1.0,
"vwo": 1.0, "vug": 1.0, "vtv": 1.0,
"xlk": 1.0, "xlf": 1.0, "xle": 0.90, "xlv": 0.95, "xlp": 1.0,
"xlu": 1.0, "xly": 0.95, "xlb": 0.95,
"vnq": 0.15,
"bil": 0.0, "shv": 0.0, "shy": 0.0, "ief": 0.0, "tlt": 0.0,
"agg": 0.0, "lqd": 0.05, "hyg": 0.0, "pff": 0.15, "emb": 0.0,
"tip": 0.10, "vweax": 0.0, "vmbix": 0.0, "finux": 0.0,
"fxe": 0.0, "fxy": 0.0, "vblix": 0.0,
"djp": 0.60, "gsg": 0.60, "gld": 0.60, "dbb": 0.60, "dbmf": 0.60,
}
# name-based strategy overrides: (regex, action, note)
# action: "cap" -> score capped at the given number; None -> note only
OVERRIDES: list[tuple[re.Pattern, float | None, str]] = [
(re.compile(r"\bmerger\b", re.I), 0.50,
"merger arb: deal gains can be short-term (deals close <1 yr) but "
"the fund also pays NII dividends + return of capital (HMEZX: "
"52% NII / 30% gains / 18% ROC over 5y) - 1099 decides"),
(re.compile(r"style premia|style and valuation", re.I), 0.50,
"long/short factor strategy: gains mix STCG/LTCG - check 1099"),
(re.compile(r"event[- ]?driven", re.I), 0.45,
"event-driven: gains mostly short-term"),
(re.compile(r"market neutral", re.I), 0.35,
"market-neutral: gains from short-dated option/systematic trades "
"- often STCG"),
(re.compile(r"global macro", re.I), None,
"macro: 60% LTCG if section-1256 futures; OTC swaps -> STCG - "
"check 1099"),
(re.compile(r"managed futures|\bCTA\b|systematic", re.I), None,
"CTA/systematic: 60/40 if section-1256 regulated futures"),
(re.compile(r"hedge (fund|strategy|strategies)", re.I), 0.50,
"hedge fund: gains often short-term - check 1099"),
(re.compile(r"absolute return", re.I), None,
"absolute-return: character varies - check 1099"),
(re.compile(r"multi[- ]?asset", re.I), None,
"multi-asset: mixed qualified/LTCG + ordinary interest - "
"check 1099"),
(re.compile(r"fund of funds", re.I), None,
"FOF: pass-through of underlying character - check 1099"),
]
MUNI_RX = re.compile(r"municipal|tax[- ]?exempt|munis\b", re.I)
MMF_RX = re.compile(r"money market", re.I)
TAXABLE_AT = 0.60 # score >= this -> taxable
IRA_AT = 0.35 # score <= this -> IRA
FOF_UNKNOWN_AT = 0.40 # >40% pass-through -> can't clear either bar
def bucket_score(buckets: list[dict]) -> tuple[float, float, list[str]]:
"""(score, unknown_frac, unknown_names) from N-PORT-style buckets."""
score = 0.0
known = 0.0
unknown = 0.0
unk_names: list[str] = []
for b in buckets:
pct = (b.get("pct") or 0.0) / 100.0
frac, _ = BUCKET_FRAC.get(b["name"], (None, b["name"]))
if frac is None:
unknown += pct
if pct >= 0.05:
unk_names.append(f"{b['name']} {pct*100:.0f}%")
else:
score += frac * pct
known += pct
if known > 0:
score /= known # renormalize over classified assets
return score, unknown, unk_names
def sleeve_score(betas: dict) -> float | None:
"""Score from sleeve loadings (proxy when no N-PORT is on file)."""
num = 0.0
den = 0.0
for s, b in (betas or {}).items():
if not isinstance(b, (int, float)) or b <= 0:
continue
f = SLEEVE_FRAC.get(s)
if f is None:
continue
num += f * b
den += b
if den < 0.05:
return None
return num / den
def classify(name: str,
buckets: list[dict] | None = None,
betas: dict | None = None) -> dict:
"""Location decision for one fund. Returns a result dict."""
notes: list[str] = []
unknown = 0.0
used_sleeves = False
if buckets:
score, unknown, unk_names = bucket_score(buckets)
basis = "N-PORT"
if unk_names:
notes.append("unclassified: " + ", ".join(unk_names))
# keyword parser left most of the book unclassified ("Other") -
# trust the return sleeves instead
if unknown > 0.5 and betas:
s2 = sleeve_score(betas)
if s2 is not None:
score, basis, used_sleeves = s2, "N-PORT+sleeves", True
notes.append("holdings mostly unclassified - used "
"return sleeves")
else:
score = sleeve_score(betas)
basis = "sleeves"
if score is None:
score = 0.5
notes.append("no clean sleeve match - treat as mixed")
score = min(max(score, 0.0), 1.0)
is_muni = bool(MUNI_RX.search(name or ""))
is_mmf = bool(MMF_RX.search(name or ""))
if is_muni:
score = 1.0 # all distributions are tax-exempt = tax-favorable
# strategy overrides
cap: float | None = None
for rx, act, note in OVERRIDES:
if rx.search(name or ""):
notes.append(note)
if act is not None:
cap = min(cap, act) if cap is not None else act
if cap is not None:
score = min(score, cap)
# location
if is_muni:
loc = "TAXABLE (munis)"
notes.append("tax-exempt interest - keep OUT of the IRA")
elif is_mmf:
loc = "FLEXIBLE (cash)"
notes.append("ordinary interest, no placement value either way")
elif not used_sleeves and unknown > FOF_UNKNOWN_AT:
loc = "MIXED (check 1099)"
notes.append(f"{unknown*100:.0f}% pass-through/unclassified - "
"character is the underlying funds'")
elif score >= TAXABLE_AT:
loc = "TAXABLE"
elif score <= IRA_AT:
loc = "IRA"
else:
loc = "MIXED (check 1099)"
return {"name": name, "basis": basis, "score": round(score, 2),
"unknown": round(unknown, 2), "location": loc,
"notes": "; ".join(dict.fromkeys(notes))}
# Manual overrides for funds the structural data can't resolve - each
# with the reason it's needed. (LCORX/LCRIX are new share classes with
# no return history; the NPORT shows they are a 91.7% wrapper around the
# Leuthold Core ETF, a US equity fund.)
MANUAL: dict[str, tuple[str, str]] = {
"LCORX": ("TAXABLE", "wrapper: 91.7% Leuthold Core ETF (US equity) "
"+ 8% money market"),
"LCRIX": ("TAXABLE", "wrapper: 91.7% Leuthold Core ETF (US equity) "
"+ 8% money market"),
}
SPLIT = HERE / "taxsplit_results.json"
ROC_FILE = HERE / "roc_results.json"
DEFER_SHARE_MIN = 0.50 # >= half of the 5y return DEFERS TO THE INVESTOR
# (price appreciation and/or return of capital),
# realized as the investor's own LTCG on a >1y
# sale - taxed at the LTCG rate, NOT the ordinary
# rate a traditional IRA would apply at withdrawal
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 _rocs() -> dict:
"""sym -> parsed per-share table from the N-CSR (roc_results.json)."""
if not ROC_FILE.exists():
return {}
d = json.loads(ROC_FILE.read_text())
return {s.upper(): v["table"] for s, v in d.items()
if isinstance(v, dict) and v.get("table")}
def _deferred_share(sym: str, s: dict | None) -> tuple[float, float]:
"""Share of the 5y total return that DEFERS TO THE INVESTOR (LTCG on
a >1y sale): price appreciation + return of capital.
ROC mechanics: the distribution is tax-free now (it reduces basis);
at sale it reappears inside YOUR capital gain - LTCG if you held
>1y. In a traditional IRA the same money would come out as ordinary
income. So, like appreciation, ROC is a taxable-account feature.
Returns (deferred_share, roc_share_of_distributions).
"""
appr = (s or {}).get("appr_share") or 0.0
t = _rocs().get(sym.upper())
if not t or not t.get("nav") or len(t["nav"]) < 2:
return appr, 0.0
nav0, nav1 = t["nav"][-1], t["nav"][0] # oldest -> newest FY end
if not nav0 or nav0 <= 0:
return appr, 0.0
dist5 = -sum(v or 0 for v in t.get("tot", []))
roc5 = -sum(v or 0 for v in t.get("roc", []))
ret5 = nav1 / nav0 - 1 + dist5 / nav0 # per-share total return
if ret5 < 0.01:
return appr, 0.0
roc_share_dist = roc5 / dist5 if dist5 > 0 else 0.0
defer = min(1.0, max(appr, (nav1 / nav0 - 1 + roc5 / nav0) / ret5))
return defer, roc_share_dist
def finalize(sym: str, r: dict, splits: dict | None = None) -> dict:
s = (splits or _splits()).get(sym.upper())
defer, roc_share_dist = _deferred_share(sym.upper(), s)
# >= half of the 5y return defers to the investor (appreciation and/
# or return of capital): realized as the investor's OWN LTCG on a
# >1y sale at the LTCG rate - better than the ordinary rate a
# traditional IRA would apply at withdrawal.
if (defer >= DEFER_SHARE_MIN
and r["location"] in ("IRA", "MIXED (check 1099)")):
r["location"] = "TAXABLE (defers to LTCG)"
r["notes"] = (f"~{defer*100:.0f}% of 5y return defers to the "
f"investor (price appreciation + return of capital) "
f"- taxed as YOUR LTCG on a >1y sale, not ordinary "
f"income as in a traditional IRA. " + r["notes"]).strip(" ;")
elif roc_share_dist >= 0.10:
r["notes"] = (f"{roc_share_dist*100:.0f}% of 5y distributions were "
f"return of capital (basis-reducing: tax-free now, "
f"your LTCG on a >1y sale - a taxable-account "
f"feature). " + r["notes"]).strip(" ;")
if sym.upper() in MANUAL:
loc, note = MANUAL[sym.upper()]
r["location"] = loc
r["score"] = 1.0 if loc == "TAXABLE" else 0.0
r["basis"] = (r["basis"] + "+manual").lstrip("+")
r["notes"] = (note + "; " + r["notes"]).strip("; ")
return r
# ------------------------------------------------------------------------
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.
fac = json.loads(FACTORS.read_text())
extra = (json.loads(EXTRA_BETAS.read_text())
if EXTRA_BETAS.exists() else {})
def betas_of(sym: str) -> dict:
v = fac.get(sym.lower()) or fac.get(sym.upper()) or {}
b = (v.get("full") or {}).get("betas")
if b:
return b
key = sym.lower()
if key in extra:
return extra[key]
from fundlab import factors
r = factors.factor_screen(sym)
if r and r.get("full"):
extra[key] = r["full"]["betas"]
EXTRA_BETAS.parent.mkdir(exist_ok=True)
EXTRA_BETAS.write_text(json.dumps(extra, indent=1))
return extra.get(key) or {}
# 16-fund shortlist (N-PORT keyword buckets + names from funds.json)
funds = json.loads((HERE.parent / "funds.json").read_text())
shortlist: dict = {}
dr = json.loads(SHORTLIST.read_text())
for sym in dr:
snap = json.loads((HERE / "nport_cache" / f"{sym}.json").read_text())
name = (funds.get(sym.lower()) or {}).get("name") or sym.upper()
r = classify(name, buckets=snap.get("buckets") or None,
betas=betas_of(sym))
r["as_of"] = snap.get("as_of", "")
shortlist[sym.upper()] = finalize(sym, r)
# 22 cross-checked (precise code-based buckets)
xcheck: dict = {}
xc = json.loads(XCHECK.read_text())
for sym, v in xc.items():
r = classify(v.get("name") or sym.upper(),
buckets=v.get("buckets") or None,
betas=betas_of(sym))
r["as_of"] = v.get("as_of", "")
xcheck[sym.upper()] = finalize(sym, r)
# 250 candidates (sleeve loadings + name)
cands: dict = {}
for sym, v in fac.items():
if not (v.get("verdict") or "").startswith("CANDIDATE"):
continue
betas = (v.get("full") or {}).get("betas") or {}
cands[sym.upper()] = finalize(sym, classify(v.get("name") or "",
betas=betas))
res = {"shortlist": shortlist, "xcheck": xcheck, "candidates": cands}
RESULTS.write_text(json.dumps(res, indent=1))
_print(res)
return res
def _print(res: dict) -> None:
from collections import Counter
def row(s: str, r: dict) -> str:
return (f" {s:<7} {r['location']:<20} {r['score']:<5.2f} "
f"{r['basis']:<14} {r['name'][:34]:<34} {r['notes'][:64]}")
hdr = (f" {'fund':<7} {'location':<20} score basis "
f"{'name':<34} notes")
for key, title in (("shortlist", "16-fund shortlist"),
("xcheck", "22 cross-checked")):
funds = res[key]
print(f"\n== {title} ({len(funds)}) ==")
print(hdr)
for s, r in sorted(funds.items()):
print(row(s, r))
c = res["candidates"]
locs = Counter(r["location"] for r in c.values())
print(f"\n== 250 candidates: {dict(locs)} ==")
sections = [
("TAXABLE - equity character (by score)",
lambda r: r["location"] == "TAXABLE", -18, 18),
("TAXABLE - munis (sample)",
lambda r: r["location"] == "TAXABLE (munis)", 0, 12),
("MIXED - check the 1099 (by score)",
lambda r: r["location"] == "MIXED (check 1099)", -12, 12),
("IRA - most ordinary income (by score)",
lambda r: r["location"] == "IRA", 12, 12),
("FLEXIBLE - cash",
lambda r: r["location"] == "FLEXIBLE (cash)", 0, 8),
]
for title, pick, sortkey, limit in sections:
sel = [(s, r) for s, r in c.items() if pick(r)]
if sortkey:
sel.sort(key=lambda kv: (sortkey * kv[1]["score"], kv[0]))
else:
sel.sort(key=lambda kv: kv[0])
print(f"\n -- {title} ({len(sel)})")
print(hdr)
for s, r in sel[:limit]:
print(row(s, r))
if __name__ == "__main__":
run()