Tax-location plan: taxable account vs IRA per fund
fundlab/taxplan.py categorizes the 16-fund shortlist, the 22 N-PORT cross-checked candidates, and all 250 screened candidates by the expected CHARACTER of their distributions, given the user's premise that the current LTCG rate < the post-retirement ordinary rate: qualified div + LTCG -> TAXABLE (score >= 0.60) tax-exempt (munis) -> TAXABLE ordinary / STCG / REIT -> IRA (score <= 0.35) in between -> MIXED (pull the 1099-DIV) cash -> FLEXIBLE score = estimated share of distributions that are tax-favorable, from three tiers of ground truth: N-PORT keyword buckets (16), SEC assetCat/issuerCat buckets (22), sleeve loadings (250), with a sleeve fallback when the keyword parser left >50% of a book unclassified, and a manual override for the Leuthold wrappers (91.7% Leuthold Core ETF, no return history yet). Key findings: - shortlist: TAXABLE = ATESX, JLPSX, LAMHX, LCORX, LCRIX (equity); IRA = ATRFX, COSIX, CVSIX, PMORX, SVARX, EAGMX/EGRSX; MIXED = MBXIX, QSPNX, PMAIX/PMFKX (same fund, two classes) - cross-checked: 4 munis -> TAXABLE; HMEZX + MERVX are the merger- arb trap - equity-looking books whose distributions are mostly SHORT-TERM gains -> IRA - candidates: 109 munis TAXABLE, 127 IRA, 6 equity TAXABLE, 7 MIXED App: Fund Lab "Tax location" expander. Output: fundlab/taxplan_results.json. Tests: test_taxplan() (9 checks). 97/32 suites green.
This commit is contained in:
parent
d0ae2ec348
commit
8a9ca25750
62
app.py
62
app.py
|
|
@ -893,6 +893,68 @@ with tab_fundlab:
|
||||||
"funds with 4/5 (merger arb, market-neutral, "
|
"funds with 4/5 (merger arb, market-neutral, "
|
||||||
"securitized credit) that still earned their 5y alpha.")
|
"securitized credit) that still earned their 5y alpha.")
|
||||||
|
|
||||||
|
# ---- tax location: taxable account vs IRA -------------------------
|
||||||
|
with st.expander(
|
||||||
|
"Tax location - which account (taxable vs IRA) for each fund"):
|
||||||
|
_TP = _dc.RESULTS.parent / "taxplan_results.json"
|
||||||
|
if not _TP.exists():
|
||||||
|
st.info("No tax-location screen on file yet "
|
||||||
|
"(run `python -m fundlab.taxplan`).")
|
||||||
|
else:
|
||||||
|
_tp = json.loads(_TP.read_text())
|
||||||
|
st.caption(
|
||||||
|
"Where each fund's distributions should live, given the "
|
||||||
|
"current LTCG rate < future ordinary rate. Basis: N-PORT "
|
||||||
|
"holdings (16 shortlist + 22 cross-checked) or return-"
|
||||||
|
"sleeve proxy (250 candidates). 'score' = estimated share "
|
||||||
|
"of distributions that are tax-favorable (qualified "
|
||||||
|
"dividends + LTCG + tax-exempt). MIXED funds: pull the "
|
||||||
|
"last 1099-DIV - it is the final arbiter.")
|
||||||
|
|
||||||
|
def _tp_table(funds: dict) -> pd.DataFrame:
|
||||||
|
rows = []
|
||||||
|
for s, r in sorted(funds.items()):
|
||||||
|
rows.append({
|
||||||
|
"fund": s,
|
||||||
|
"name": r["name"][:44],
|
||||||
|
"location": r["location"],
|
||||||
|
"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}
|
||||||
|
df = pd.DataFrame(rows)
|
||||||
|
df["_o"] = df["location"].map(
|
||||||
|
lambda x: order.get(x, 5))
|
||||||
|
df = df.sort_values(["_o", "score"],
|
||||||
|
ascending=[True, False])
|
||||||
|
return df.drop(columns="_o")
|
||||||
|
|
||||||
|
st.markdown("**16-fund shortlist**")
|
||||||
|
st.dataframe(_tp_table(_tp["shortlist"]), width="stretch")
|
||||||
|
st.markdown("**22 cross-checked**")
|
||||||
|
st.dataframe(_tp_table(_tp["xcheck"]), width="stretch")
|
||||||
|
_sel = st.selectbox(
|
||||||
|
"Candidates (250)",
|
||||||
|
["All locations", "TAXABLE", "TAXABLE (munis)",
|
||||||
|
"MIXED (check 1099)", "IRA", "FLEXIBLE (cash)"],
|
||||||
|
key="_tp_loc")
|
||||||
|
_c = _tp["candidates"]
|
||||||
|
if _sel != "All locations":
|
||||||
|
_c = {s: r for s, r in _c.items()
|
||||||
|
if r["location"] == _sel}
|
||||||
|
st.dataframe(_tp_table(_c), width="stretch")
|
||||||
|
st.caption(
|
||||||
|
"Reading the table: TAXABLE = income is mostly qualified "
|
||||||
|
"dividends/LTCG (or tax-exempt) - the taxable account's "
|
||||||
|
"low LTCG rate is the benefit. IRA = ordinary interest / "
|
||||||
|
"STCG / non-qualified - deferral is the benefit. FLEXIBLE "
|
||||||
|
"= cash, no placement value either way. Note the merger-"
|
||||||
|
"arb trap: equity-looking books that distribute mostly "
|
||||||
|
"SHORT-TERM gains (HMEZX, MERVX) belong in the IRA.")
|
||||||
|
|
||||||
_f = _FUNDS.get(_fl_pick, {})
|
_f = _FUNDS.get(_fl_pick, {})
|
||||||
_man = _MAN.get(_fl_pick, {})
|
_man = _MAN.get(_fl_pick, {})
|
||||||
st.subheader(f"{_f.get('name', _fl_pick)} · {_fl_pick.upper()}")
|
st.subheader(f"{_f.get('name', _fl_pick)} · {_fl_pick.upper()}")
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,54 @@ not a gate.
|
||||||
4. CEF universe (485/N-2 filers) - separate pass; CEFs have
|
4. CEF universe (485/N-2 filers) - separate pass; CEFs have
|
||||||
premium/discount dynamics the NAV screen can't see.
|
premium/discount dynamics the NAV screen can't see.
|
||||||
|
|
||||||
|
### 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:
|
||||||
|
- qualified dividends + LTCG -> TAXABLE (low LTCG rate is the benefit)
|
||||||
|
- tax-exempt (munis) -> TAXABLE (wasted in an IRA)
|
||||||
|
- ordinary interest / STCG / REIT / K-1 -> IRA (deferral is the benefit)
|
||||||
|
- cash -> FLEXIBLE
|
||||||
|
|
||||||
|
No 1099-DIV characterizations on file for 2,400 funds, so this is a
|
||||||
|
STRUCTURAL estimate. `score` = estimated share of distributions that
|
||||||
|
are tax-favorable (QD + LTCG + tax-exempt), from three tiers of ground
|
||||||
|
truth:
|
||||||
|
1. 16-fund shortlist -> nport_cache buckets (keyword)
|
||||||
|
2. 22 cross-checked -> xcheck_report buckets (SEC assetCat/issuerCat)
|
||||||
|
3. 250 candidates -> factor sleeve loadings (return proxy)
|
||||||
|
Fallback: if the keyword parser left >50% of a book unclassified
|
||||||
|
("Other"), use the return sleeves. Manual override for the Leuthold
|
||||||
|
wrappers (no return history).
|
||||||
|
|
||||||
|
Location bands: score >= 0.60 TAXABLE, <= 0.35 IRA, else MIXED (check
|
||||||
|
the 1099). Name-based overrides: muni name -> TAXABLE (munis), money
|
||||||
|
market -> FLEXIBLE, and strategy caps (merger-arb/event-driven capped
|
||||||
|
at 0.35 because gains are mostly SHORT-TERM; market-neutral 0.35;
|
||||||
|
hedge 0.50; style-premia 0.50).
|
||||||
|
|
||||||
|
KEY FINDINGS:
|
||||||
|
- 16 shortlist: TAXABLE = ATESX, JLPSX, LAMHX, LCORX, LCRIX (all
|
||||||
|
equity). IRA = ATRFX, COSIX, CVSIX, PMORX, SVARX, EAGMX/EGRSX
|
||||||
|
(macro/market-neutral/income). MIXED (check 1099) = MBXIX (hedge),
|
||||||
|
QSPNX (AQR factor), PMAIX/PMFKX (multi-asset income, same fund two
|
||||||
|
classes).
|
||||||
|
- 22 cross-checked: the four MUNIS (BTMIX, FHMIX, HICOX, USMSX) ->
|
||||||
|
TAXABLE, everything else IRA except the macro FOFs (EGRIX MIXED,
|
||||||
|
ETSIX IRA) and DMSZX (MIXED, 38% equity + 36% CLO).
|
||||||
|
- MERGER-ARB TRAP: HMEZX + MERVX hold ~75% equity (looks tax-
|
||||||
|
efficient) but their distributions are mostly SHORT-TERM gains
|
||||||
|
(deals close <1 yr) -> IRA, not taxable. This is the one place the
|
||||||
|
equity-looking book is misleading.
|
||||||
|
- 250 candidates: 109 munis (TAXABLE), 127 IRA (bonds/credit/HY/loans),
|
||||||
|
6 equity TAXABLE (PHSTX, ANNPX, FKUTX, ALGRX, EBSAX, MCOAX), 7
|
||||||
|
MIXED, 1 FLEXIBLE. The candidate pool is credit-heavy, so most are
|
||||||
|
IRA.
|
||||||
|
|
||||||
|
App: Fund Lab -> "Tax location" expander (shortlist + cross-checked
|
||||||
|
+ 250-candidate tables, location filter). Output:
|
||||||
|
fundlab/taxplan_results.json. The last 1099-DIV is the final arbiter
|
||||||
|
for any MIXED fund.
|
||||||
|
|
||||||
### Drawdown-resilience screen (fundlab/drawdown.py, 2026-08-27)
|
### Drawdown-resilience screen (fundlab/drawdown.py, 2026-08-27)
|
||||||
Scenarios DETECTED from IVV (S&P 500) - one worst peak->trough per
|
Scenarios DETECTED from IVV (S&P 500) - one worst peak->trough per
|
||||||
calendar year since 2022, min depth 8% (2024's Aug-5 dip and 2023's
|
calendar year since 2022, min depth 8% (2024's Aug-5 dip and 2023's
|
||||||
|
|
|
||||||
396
fundlab/taxplan.py
Normal file
396
fundlab/taxplan.py
Normal file
|
|
@ -0,0 +1,396 @@
|
||||||
|
"""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.35,
|
||||||
|
"merger arb: gains are largely SHORT-TERM (deals close <1 yr) - "
|
||||||
|
"1099 will show STCG despite the equity book"),
|
||||||
|
(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"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def finalize(sym: str, r: dict) -> dict:
|
||||||
|
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:
|
||||||
|
# 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()
|
||||||
2350
fundlab/taxplan_results.json
Normal file
2350
fundlab/taxplan_results.json
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -452,6 +452,68 @@ def test_xcheck() -> None:
|
||||||
and s.endswith("Advantage Fund"), str(s))
|
and s.endswith("Advantage Fund"), str(s))
|
||||||
|
|
||||||
|
|
||||||
|
def test_taxplan() -> None:
|
||||||
|
print("taxplan", flush=True)
|
||||||
|
import fundlab.taxplan as tp
|
||||||
|
|
||||||
|
# equity book -> taxable
|
||||||
|
r = tp.classify("Some US Equity Fund",
|
||||||
|
buckets=[{"name": "Equity (common)", "pct": 80},
|
||||||
|
{"name": "Cash/MMF (short-term)", "pct": 20}])
|
||||||
|
check("equity fund -> TAXABLE", r["location"] == "TAXABLE",
|
||||||
|
f"{r['location']} {r['score']}")
|
||||||
|
|
||||||
|
# bond book -> IRA
|
||||||
|
r = tp.classify("Some Bond Fund",
|
||||||
|
buckets=[{"name": "Corporate bond", "pct": 70},
|
||||||
|
{"name": "US Treasury", "pct": 25},
|
||||||
|
{"name": "Cash/MMF (short-term)", "pct": 5}])
|
||||||
|
check("bond fund -> IRA", r["location"] == "IRA",
|
||||||
|
f"{r['location']} {r['score']}")
|
||||||
|
|
||||||
|
# muni name override wins regardless of sleeves
|
||||||
|
r = tp.classify("X Municipal Bond Fund", betas={"shv": 5.0})
|
||||||
|
check("muni name -> TAXABLE (munis)",
|
||||||
|
r["location"] == "TAXABLE (munis)" and r["score"] == 1.0,
|
||||||
|
f"{r['location']}")
|
||||||
|
|
||||||
|
# merger arb: equity book but STCG character -> capped to 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,
|
||||||
|
f"{r['location']} {r['score']}")
|
||||||
|
|
||||||
|
# money market -> flexible
|
||||||
|
r = tp.classify("Plain Money Market Account",
|
||||||
|
buckets=[{"name": "Cash/MMF (short-term)", "pct": 100}])
|
||||||
|
check("money market -> FLEXIBLE", r["location"] == "FLEXIBLE (cash)",
|
||||||
|
r["location"])
|
||||||
|
|
||||||
|
# mostly pass-through, no sleeves -> MIXED
|
||||||
|
r = tp.classify("Wrapper Fund",
|
||||||
|
buckets=[{"name": "Fund/ETF holdings", "pct": 100}])
|
||||||
|
check("100% FOF no sleeves -> MIXED",
|
||||||
|
r["location"] == "MIXED (check 1099)", r["location"])
|
||||||
|
|
||||||
|
# sleeve proxy: pure rates -> IRA; pure equity -> TAXABLE
|
||||||
|
check("sleeve proxy rates -> IRA",
|
||||||
|
tp.classify("F", betas={"tlt": 0.9, "shv": 0.2})["location"]
|
||||||
|
== "IRA", "")
|
||||||
|
check("sleeve proxy equity -> TAXABLE",
|
||||||
|
tp.classify("F", betas={"ivv": 0.8, "qqq": 0.2})["location"]
|
||||||
|
== "TAXABLE", "")
|
||||||
|
|
||||||
|
# manual override for the Leuthold wrappers
|
||||||
|
r = tp.finalize("LCORX", tp.classify("Leuthold Core Investment",
|
||||||
|
buckets=[
|
||||||
|
{"name": "Fund holdings",
|
||||||
|
"pct": 100}]))
|
||||||
|
check("LCORX manual -> TAXABLE", r["location"] == "TAXABLE",
|
||||||
|
r["location"])
|
||||||
|
|
||||||
|
|
||||||
def test_drawdown() -> None:
|
def test_drawdown() -> None:
|
||||||
print("drawdown", flush=True)
|
print("drawdown", flush=True)
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
@ -498,6 +560,7 @@ def main() -> int:
|
||||||
test_overnight()
|
test_overnight()
|
||||||
test_curated()
|
test_curated()
|
||||||
test_xcheck()
|
test_xcheck()
|
||||||
|
test_taxplan()
|
||||||
test_drawdown()
|
test_drawdown()
|
||||||
test_edgar_live()
|
test_edgar_live()
|
||||||
print(f"\n{PASS} passed, {FAIL} failed")
|
print(f"\n{PASS} passed, {FAIL} failed")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user