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:
parent
620e2499ac
commit
895efc9bff
4
app.py
4
app.py
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}])
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user