"""CEF stage 2a: tax-character proxy for the shortlist. Takes the cef_screen.json shortlist (top 5y return + crash-resilient + high-payout CEFs), runs the 35-sleeve factor screen on each, and scores the sleeve mix with taxplan.sleeve_score (same proxy the open-end candidates use). Then ranks by a crude "tax-arb per dollar" heuristic for a constrained taxable account: tax_arb ~= character x (upside + 0.4 x vol) character share of the return mix that is tax-favorable (0..1) upside max(t5,0)/5 - the LTCG-deferral term 0.4 x vol5 ~ expected |annual loss| (normal approx) - the harvest term (0.4 = 1/sqrt(2*pi)) Both terms are per dollar of balance; the heuristic is a SCREENER, the EDGAR deep dive (N-2ASR/N-CSR distribution character + NPORT NAV for the discount) is what decides. Output: fundlab/cef_character.json """ from __future__ import annotations import json import re from pathlib import Path import numpy as np from fundlab import factors, taxplan HERE = Path(__file__).parent SCREEN = HERE / "cef_screen.json" OUT = HERE / "cef_character.json" # 6(c)-converted / non-fund tickers that show up in the SEC CEF report # (organized as CEFs, later converted to operating companies) NON_FUNDS = {"cfnd", "pwrl", "bot", "fxby"} SPECULATIVE = re.compile(r"corp\.?$|tech100", re.I) def select(res: dict, n_ret: int = 40, n_pay: int = 15) -> list[str]: rows = [v for v in res.values() if v.get("t5") is not None and v["sym"] not in NON_FUNDS] by_ret = sorted(rows, key=lambda x: -x["t5"])[:n_ret] by_res = [v for v in rows if v.get("n_pos_scen", 0) >= 3] by_pay = sorted(rows, key=lambda x: -(x.get("payout_12m") or 0))[:n_pay] out: dict[str, dict] = {} for v in by_ret + by_res + by_pay: out.setdefault(v["sym"], v) return list(out) def _betas(sym: str) -> dict: fs = factors.factor_screen(sym) if not fs: return {} return (fs.get("full") or fs.get("rec5") or {}).get("betas") or {} def run() -> dict: res = json.loads(SCREEN.read_text()) syms = select(res) print(f"{len(syms)} shortlisted CEFs", flush=True) out: dict = {} for i, s in enumerate(syms, 1): v = res[s.upper()] comps = _betas(s.lower()) char = taxplan.sleeve_score(comps) if comps else None t5 = v.get("t5") or 0.0 vol = v.get("vol5") or 0.0 upside = max(t5, 0.0) / 5.0 harvest = 0.4 * vol arb = (char if char is not None else 0.3) * (upside + harvest) out[s] = { **{k: v.get(k) for k in ("name", "bdc", "days", "last")}, "sym": s, "t5": t5, "t12": v.get("t12"), "vol5": vol, "maxdd5": v.get("maxdd5"), "2022": v.get("2022 bear mkt"), "2023": v.get("2023 rate shock"), "2025t": v.get("2025 tariff crash"), "2026": v.get("2026 Q1 drawdown"), "n_pos_scen": v.get("n_pos_scen"), "payout_12m": v.get("payout_12m"), "character": round(char, 2) if char is not None else None, "sleeves": {k: round(b, 2) for k, b in comps.items() if abs(b) > 0.15}, "tax_arb": round(arb, 4), } print(f"{i:2}/{len(syms)} {s:7} char={out[s]['character']} " f"arb={out[s]['tax_arb']}", flush=True) OUT.write_text(json.dumps(out, indent=1, default=str)) print(f"wrote {OUT}") return out def _pc(v: float | None) -> str: return "" if v is None else f"{v:+.1%}" def _print(out: dict, top: int = 35) -> None: rows = sorted(out.values(), key=lambda x: -x["tax_arb"]) print(f"{'fund':7} {'name':40} {'char':>5} {'5yTR':>8} {'vol':>6}" f" {'maxDD':>8} {'2022':>8} {'2026':>8} {'dist12':>8} {'tax_arb':>8}") for v in rows[:top]: c = v["character"] print(f"{v['sym']:7} {v.get('name','')[:40]:40} " f"{('' if c is None else f'{c:.2f}'):>5} " f"{v['t5']:+8.1%} {v['vol5']:6.2f} " f"{_pc(v.get('maxdd5')):>8} {_pc(v.get('2022')):>8} " f"{_pc(v.get('2026')):>8} {_pc(v.get('payout_12m')):>8} " f"{v['tax_arb']:8.3f}") if __name__ == "__main__": o = run() _print(o)