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.
This commit is contained in:
Greg Pomerantz 2026-08-27 17:11:55 -04:00
parent 620e2499ac
commit 895efc9bff
5 changed files with 152 additions and 47 deletions

4
app.py
View File

@ -938,7 +938,7 @@ with tab_fundlab:
"basis": r["basis"],
"notes": r["notes"][:120],
})
order = {"TAXABLE": 0, "TAXABLE (accrues)": 1,
order = {"TAXABLE": 0, "TAXABLE (defers to LTCG)": 1,
"TAXABLE (munis)": 2,
"MIXED (check 1099)": 3, "IRA": 4,
"FLEXIBLE (cash)": 5, "NO DATA": 9}
@ -955,7 +955,7 @@ with tab_fundlab:
st.dataframe(_tp_table(_tp["xcheck"]), width="stretch")
_sel = st.selectbox(
"Candidates (250)",
["All locations", "TAXABLE", "TAXABLE (accrues)",
["All locations", "TAXABLE", "TAXABLE (defers to LTCG)",
"TAXABLE (munis)",
"MIXED (check 1099)", "IRA", "FLEXIBLE (cash)"],
key="_tp_loc")

View File

@ -181,10 +181,41 @@ NAV eroded $11.87 -> $7.88 (-34%) over the 5 shown fiscal years
while paying out every year. Classic constant-distribution principal
erosion.
Placement consequence: ROC does NOT help the taxable-account case -
basis deferral in a taxable account is what a traditional IRA gives
you anyway, and it signals principal erosion. The taxable-friendly
character remains qualified dividends / LTCG.
**Placement consequence (CORRECTED after user pushback - the first
draft of this section was WRONG):** ROC IS a taxable-account
feature, exactly like NAV appreciation. Mechanics: the ROC payment
is tax-free now (reduces your basis); at sale it reappears inside
YOUR capital gain - LTCG if you held >1y, at the LTCG rate. In a
traditional IRA the same dollars come out as ORDINARY income at
withdrawal. The two are not "the same deferral": taxable defers to
the LOWER rate, the traditional IRA defers to the HIGHER one. Under
the user's premise (LTCG rate < future ordinary rate), a fund whose
return defers to the investor (appreciation or ROC) belongs in the
taxable account. Numerically: $1,000 return - taxable: 15% LTCG =
$150; traditional IRA: 32% ordinary = $320. (Contrast: ordinary
income - interest, ordinary divs, STCG - is taxed at the ordinary
rate in BOTH accounts, so there only the IRA's deferral/time-value
wins.)
taxplan.py now computes, per fund, the deferred-to-investor share of
the 5y return = max(appreciation share, (NAV change + ROC)/total
return, using the parsed per-share table), and:
- >= 50% deferred -> "TAXABLE (defers to LTCG)" (renamed from
"TAXABLE (accrues)");
- 10-50% of distributions were ROC -> note (counterweight, no
location flip).
Merger-arb cap relaxed 0.35 -> 0.50: HMEZX's per-share table
(52% NII / 30% realized gains / 18% ROC over 5y) refutes the
"mostly STCG" assumption - HMEZX/MERVX are now MIXED (check 1099),
not clean IRA.
IMPORTANT - keep the tax question separate from the QUALITY
question: a fund paying out a lot of ROC may be returning PRINCIPAL
(payout > earnings; PGSIX's NAV -34% over 5y). That is a
sustainability/real-return red flag to weigh in the selection, but
it does not change the tax-optimal account. You can prefer a fund's
ROC/appreciation profile for the taxable account while still being
skeptical of its return durability.
### Price-appreciation vs. payout split (fundlab/taxsplit.py, 2026-08-27)
Follow-up to the tax-location plan: the user pointed out that fund

View File

@ -126,9 +126,10 @@ SLEEVE_FRAC: dict[str, float] = {
# 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.35,
"merger arb: gains are largely SHORT-TERM (deals close <1 yr) - "
"1099 will show STCG despite the equity book"),
(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,
@ -276,7 +277,12 @@ MANUAL: dict[str, tuple[str, str]] = {
SPLIT = HERE / "taxsplit_results.json"
APPR_SHARE_MIN = 0.50 # >= half of the 5y return is price appreciation
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:
@ -292,21 +298,61 @@ def _splits() -> dict:
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())
# 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
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 (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(" ;")
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

View File

@ -23,8 +23,8 @@
"basis": "sleeves",
"score": 0.35,
"unknown": 0.0,
"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",
"location": "TAXABLE (defers to LTCG)",
"notes": "~61% of 5y return defers to the investor (price appreciation + return of capital) - taxed as YOUR LTCG on a >1y sale, not ordinary income as in a traditional IRA. 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": "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",
"location": "TAXABLE (defers to LTCG)",
"notes": "~76% of 5y return defers to the investor (price appreciation + return of capital) - taxed as YOUR LTCG on a >1y sale, not ordinary income as in a traditional IRA. 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": {
@ -158,10 +158,10 @@
"HMEZX": {
"name": "NexPoint Merger Arbitrage Fund",
"basis": "N-PORT",
"score": 0.35,
"score": 0.5,
"unknown": 0.0,
"location": "IRA",
"notes": "merger arb: gains are largely SHORT-TERM (deals close <1 yr) - 1099 will show STCG despite the equity book",
"location": "MIXED (check 1099)",
"notes": "18% of 5y distributions were return of capital (basis-reducing: tax-free now, your LTCG on a >1y sale - a taxable-account feature). 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",
"as_of": "2026-06-01"
},
"COIAX": {
@ -293,10 +293,10 @@
"MERVX": {
"name": "The Merger Fund VL",
"basis": "N-PORT",
"score": 0.35,
"score": 0.5,
"unknown": 0.0,
"location": "IRA",
"notes": "merger arb: gains are largely SHORT-TERM (deals close <1 yr) - 1099 will show STCG despite the equity book",
"location": "MIXED (check 1099)",
"notes": "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",
"as_of": "2026-05-29"
},
"AGUAX": {
@ -331,8 +331,8 @@
"basis": "N-PORT",
"score": 0.01,
"unknown": 0.02,
"location": "IRA",
"notes": "",
"location": "TAXABLE (defers to LTCG)",
"notes": "~100% of 5y return defers to the investor (price appreciation + return of capital) - taxed as YOUR LTCG on a >1y sale, not ordinary income as in a traditional IRA.",
"as_of": "2026-06-24"
},
"LPXAX": {
@ -340,8 +340,8 @@
"basis": "N-PORT",
"score": 0.05,
"unknown": 0.0,
"location": "IRA",
"notes": "",
"location": "TAXABLE (defers to LTCG)",
"notes": "~100% of 5y return defers to the investor (price appreciation + return of capital) - taxed as YOUR LTCG on a >1y sale, not ordinary income as in a traditional IRA.",
"as_of": "2026-06-24"
}
},
@ -991,8 +991,8 @@
"basis": "sleeves",
"score": 0.06,
"unknown": 0.0,
"location": "IRA",
"notes": ""
"location": "TAXABLE (defers to LTCG)",
"notes": "~100% of 5y return defers to the investor (price appreciation + return of capital) - taxed as YOUR LTCG on a >1y sale, not ordinary income as in a traditional IRA."
},
"FFRSX": {
"name": "Federated Hermes Floating Rate Strategic Income Fund",
@ -1352,7 +1352,7 @@
"score": 0.11,
"unknown": 0.0,
"location": "IRA",
"notes": "merger arb: gains are largely SHORT-TERM (deals close <1 yr) - 1099 will show STCG despite the equity book"
"notes": "18% of 5y distributions were return of capital (basis-reducing: tax-free now, your LTCG on a >1y sale - a taxable-account feature). 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"
},
"HUBAX": {
"name": "HARTFORD ULTRASHORT BOND HLS FUND",
@ -1431,8 +1431,8 @@
"basis": "sleeves",
"score": 0.08,
"unknown": 0.0,
"location": "IRA",
"notes": ""
"location": "TAXABLE (defers to LTCG)",
"notes": "~100% of 5y return defers to the investor (price appreciation + return of capital) - taxed as YOUR LTCG on a >1y sale, not ordinary income as in a traditional IRA."
},
"LSBDX": {
"name": "Loomis Sayles Income Fund",
@ -1504,7 +1504,7 @@
"score": 0.24,
"unknown": 0.0,
"location": "IRA",
"notes": "merger arb: gains are largely SHORT-TERM (deals close <1 yr) - 1099 will show STCG despite the equity book"
"notes": "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"
},
"MFALX": {
"name": "MFS Alabama Municipal Bond Fund",
@ -1791,8 +1791,8 @@
"basis": "sleeves",
"score": 0.23,
"unknown": 0.0,
"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."
"location": "TAXABLE (defers to LTCG)",
"notes": "~60% of 5y return defers to the investor (price appreciation + return of capital) - taxed as YOUR LTCG on a >1y sale, not ordinary income as in a traditional IRA."
},
"PBCAX": {
"name": "PGIM CALIFORNIA MUNI INCOME FUND",

View File

@ -477,14 +477,42 @@ def test_taxplan() -> None:
r["location"] == "TAXABLE (munis)" and r["score"] == 1.0,
f"{r['location']}")
# merger arb: equity book but STCG character -> capped to IRA
# merger arb: deal gains can be STCG but NII/ROC are mixed -> capped
# to the MIXED band (the 1099 decides), not a clean IRA
r = tp.classify("The Merger Fund",
buckets=[{"name": "Equity (common)", "pct": 90},
{"name": "Cash/MMF (short-term)", "pct": 10}])
check("merger arb capped (STCG) -> IRA",
r["location"] == "IRA" and r["score"] <= 0.35,
check("merger arb capped (STCG) -> MIXED",
r["location"] == "MIXED (check 1099)" and 0.35 < r["score"] <= 0.50,
f"{r['location']} {r['score']}")
# ROC: >= half of the 5y return defers to the investor (LTCG on a
# >1y sale) -> upgraded out of IRA; smaller ROC -> note only
splits = {"X": {"appr_share": 0.05, "payout_12m": 0.03, "tot": 0.40}}
r1 = tp.classify("ROC Fund",
buckets=[{"name": "Bond (corporate)", "pct": 80},
{"name": "Cash/MMF (short-term)", "pct": 20}])
r1["location"] = "IRA" # what the score alone gave
_real_rocs = tp._rocs
# per-share tables record distributions as NEGATIVES (declared amounts)
tp._rocs = lambda: {"X": {"nav": [100.0, 100.0], "tot": [-5.0, -5.0],
"roc": [-4.5, -4.5], "nii": [-0.5, -0.5],
"gains": []}}
tp.finalize("X", r1, splits)
check("ROC-heavy fund upgraded (defers to LTCG)",
r1["location"] == "TAXABLE (defers to LTCG)", r1["location"])
r2 = tp.classify("ROC Fund",
buckets=[{"name": "Corporate bond", "pct": 80},
{"name": "Cash/MMF (short-term)", "pct": 20}])
tp._rocs = lambda: {"X": {"nav": [100.0, 100.0], "tot": [-5.0, -5.0],
"roc": [-1.5, -1.5], "nii": [-3.5, -3.5],
"gains": []}}
tp.finalize("X", r2, splits)
check("small ROC -> note, not a location change",
r2["location"] == "IRA" and "return of capital" in r2["notes"],
r2["location"] + " " + r2["notes"][:60])
tp._rocs = _real_rocs
# money market -> flexible
r = tp.classify("Plain Money Market Account",
buckets=[{"name": "Cash/MMF (short-term)", "pct": 100}])