From 895efc9bffa2416c4a70b6ca10dad01977f519ad Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Thu, 27 Aug 2026 17:11:55 -0400 Subject: [PATCH] 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. --- app.py | 4 +- fundlab/RESEARCH.md | 39 ++++++++++++++++-- fundlab/taxplan.py | 78 ++++++++++++++++++++++++++++-------- fundlab/taxplan_results.json | 44 ++++++++++---------- tests/test_fundlab.py | 34 ++++++++++++++-- 5 files changed, 152 insertions(+), 47 deletions(-) diff --git a/app.py b/app.py index 3749627..ff26f50 100644 --- a/app.py +++ b/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") diff --git a/fundlab/RESEARCH.md b/fundlab/RESEARCH.md index 8011497..9507d80 100644 --- a/fundlab/RESEARCH.md +++ b/fundlab/RESEARCH.md @@ -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 diff --git a/fundlab/taxplan.py b/fundlab/taxplan.py index 52c0032..3b8fde9 100644 --- a/fundlab/taxplan.py +++ b/fundlab/taxplan.py @@ -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 diff --git a/fundlab/taxplan_results.json b/fundlab/taxplan_results.json index 76845f6..11b2698 100644 --- a/fundlab/taxplan_results.json +++ b/fundlab/taxplan_results.json @@ -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", diff --git a/tests/test_fundlab.py b/tests/test_fundlab.py index 20225b9..c00b8e0 100644 --- a/tests/test_fundlab.py +++ b/tests/test_fundlab.py @@ -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}])