From a09861f39f4019ee7e8a99a641f0e7984eaf734f Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Thu, 27 Aug 2026 12:43:17 -0400 Subject: [PATCH] N-PORT cross-check: verify top candidates' actual holdings fundlab/xcheck.py - for each screen candidate, resolve the fund's OWN registrant CIK (browse-edgar; the 497-cover CIK is the family/trust), get the exact series name for the ticker (the only reliable disambiguator between sibling funds), walk the 4 most recent NPORT-P filing dates, and parse holdings from the interactive NPORT XML (primary_doc.xml at the accession root - NOT the XSL-rendered view the submissions API points at). Exact seriesName match > best htm exhibit parse. Buckets from the authoritative assetCat+issuerCat codes (ABS-O, ABS-CBDO, DBT+UST/CORP/MUN/NUSS, LON, STIV, RA, EC+RF=fund, ...), not position-name keywords. Resumable; raw filings cached under nport_cache/raw/ (gitignored). nport.py - _SECTION gains the "INVESTMENT PORTFOLIO (unaudited)" variant (NPORT-EX Sch-F files); find_section/build gain a frac token-tolerance param (Yahoo names drift from filing names); CMBS/ABS bucket gains CLO/CBDO/DAC terms. app Fund Lab - "N-PORT cross-check" expander: per-candidate table (as-of, n, t5, top code-bucket, #1 position) + per-fund holdings detail. RESEARCH.md - cross-check verdicts. 21/22 resolved (qcmmrx is an MMF, no holdings). The screen's top names are REAL: - hmezx/mervx = genuine merger arb (equity in deal targets + escrow) - egrix = 100% wrapper in one macro managed portfolio (underlying not NPORT-disclosed); etsix = fund of EV internal multi-strat accounts - wmnux = discounted/zero-coupon corporate bonds + equity swaps (the "equity names" are bond issuers/swap underlyings) - scfzx/rctix/aflix = securitized credit/CLO/distressed/levered loans - hicox/fhmix/usmsx/btmix (munis), aguax/femdx (EM sovereign), anglx (agency MBS), lpxax (rotated out of prefs into bank/financial debt) = genuine missing-factor exposures the 35-sleeve model lacks - fhcox/dultx/safex = short-duration carry (a short-duration sleeve would explain them) tests/test_fundlab.py - test_xcheck (14 checks): parse_interactive, code buckets, name-match normalization, series-name disambiguation. Also: untrack fundlab/streamlit.log; gitignore raw/ + xcheck_run.log. 84 fundlab / 32 app / 14 data tests pass. --- .gitignore | 3 + app.py | 59 + fundlab/RESEARCH.md | 90 +- fundlab/nport.py | 33 +- fundlab/nport_cache/aflix.json | 245 +++ fundlab/nport_cache/aguax.json | 472 +++++ fundlab/nport_cache/anglx.json | 472 +++++ fundlab/nport_cache/btmix.json | 482 +++++ fundlab/nport_cache/cik_map.json | 25 + fundlab/nport_cache/dultx.json | 197 +++ fundlab/nport_cache/egrix.json | 51 + fundlab/nport_cache/etsix.json | 51 + fundlab/nport_cache/fhcox.json | 136 ++ fundlab/nport_cache/fhmix.json | 368 ++++ fundlab/nport_cache/hicox.json | 287 +++ fundlab/nport_cache/hmezx.json | 334 ++++ fundlab/nport_cache/lpxax.json | 287 +++ fundlab/nport_cache/rctix.json | 352 ++++ fundlab/nport_cache/scfzx.json | 21 + fundlab/nport_cache/usmsx.json | 482 +++++ fundlab/nport_cache/wmnux.json | 130 ++ fundlab/nport_manifest.json | 84 + fundlab/streamlit.log | 11 - fundlab/xcheck.py | 481 +++++ fundlab/xcheck_report.json | 2848 ++++++++++++++++++++++++++++++ tests/test_fundlab.py | 71 + 26 files changed, 8046 insertions(+), 26 deletions(-) create mode 100644 fundlab/nport_cache/aflix.json create mode 100644 fundlab/nport_cache/aguax.json create mode 100644 fundlab/nport_cache/anglx.json create mode 100644 fundlab/nport_cache/btmix.json create mode 100644 fundlab/nport_cache/cik_map.json create mode 100644 fundlab/nport_cache/dultx.json create mode 100644 fundlab/nport_cache/egrix.json create mode 100644 fundlab/nport_cache/etsix.json create mode 100644 fundlab/nport_cache/fhcox.json create mode 100644 fundlab/nport_cache/fhmix.json create mode 100644 fundlab/nport_cache/hicox.json create mode 100644 fundlab/nport_cache/hmezx.json create mode 100644 fundlab/nport_cache/lpxax.json create mode 100644 fundlab/nport_cache/rctix.json create mode 100644 fundlab/nport_cache/scfzx.json create mode 100644 fundlab/nport_cache/usmsx.json create mode 100644 fundlab/nport_cache/wmnux.json delete mode 100644 fundlab/streamlit.log create mode 100644 fundlab/xcheck.py create mode 100644 fundlab/xcheck_report.json diff --git a/.gitignore b/.gitignore index a122371..3b2a7d7 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,7 @@ __pycache__/ settings.json funds.json fundlab/nport_cache/*.html +fundlab/nport_cache/raw/ fundlab/universe_cache/ +fundlab/xcheck_run.log +fundlab/streamlit.log diff --git a/app.py b/app.py index d397e04..65746a7 100644 --- a/app.py +++ b/app.py @@ -778,6 +778,65 @@ with tab_fundlab: except Exception as e: # noqa: BLE001 st.warning(f"cluster view unavailable: {e}") + # ---- N-PORT cross-check: what the top candidates actually hold ---- + with st.expander( + "N-PORT cross-check - what the top candidates actually hold"): + _XC = _dc.RESULTS.parent / "xcheck_report.json" + if not _XC.exists(): + st.info("No cross-check on file yet " + "(run `python -m fundlab.xcheck`).") + else: + from fundlab.xcheck import _fnum as _xfnum + _x = json.loads(_XC.read_text()) + _x = {k: v for k, v in _x.items() if not v.get("error")} + st.caption( + "Each candidate's returns said 'alpha'; this checks the " + "filing. Holdings pulled from the fund's own NPORT-P, " + "matched by exact series name (so a sibling fund's " + "book is never shown). Buckets are keyword guesses on " + "position names - read the top positions.") + _xrows = [] + for _s, _r in _x.items(): + _b = ", ".join(f"{b['name']} {b['pct']:.0f}%" + for b in _r.get("buckets", [])[:3]) + _top = _r.get("top", [{}])[0] + _tn = (_top.get("name") or _top.get("title") + or _top.get("text") or "")[:40] + _xrows.append({ + "fund": _s.upper(), "as of": _r.get("as_of", ""), + "n": _r.get("n_positions", 0), + "t5": _r.get("t5"), + "top bucket": _b or "—", "#1 position": _tn, + "note": _r.get("note", "")[:60]}) + _xrows.sort(key=lambda r: -abs(r["t5"] or 0)) + st.dataframe(pd.DataFrame(_xrows), width="stretch") + _xp = st.selectbox( + "fund holdings detail", list(_x.keys()), + key="fl_xc_pick") + _xr = _x[_xp] + st.markdown(f"**{_xp.upper()}** - {_xr.get('name','')} " + f"(as of {_xr.get('as_of')}, " + f"{_xr.get('n_positions')} positions, " + f"source: {_xr.get('src','')})") + if _xr.get("note"): + st.warning(_xr["note"]) + if _xr.get("categories"): + st.dataframe( + pd.DataFrame(_xr["categories"]), width="stretch") + _xtop = [] + for _p in _xr.get("top", []): + if "pct" in _p: + _pct = _xfnum(_p.get("pct")) + _xtop.append({ + "pos": f"{_pct:+.2f}%" if _pct is not None else "?", + "name": (_p.get("name") or _p.get("title") or "") + [:60], "cat": _p.get("asset_cat", "")}) + else: + _xtop.append({"pos": f"${_p['value']:,.0f}", + "name": _p.get("text", "")[:60], + "cat": _p.get("cat", "")}) + st.dataframe(pd.DataFrame(_xtop), width="stretch") + _f = _FUNDS.get(_fl_pick, {}) _man = _MAN.get(_fl_pick, {}) st.subheader(f"{_f.get('name', _fl_pick)} · {_fl_pick.upper()}") diff --git a/fundlab/RESEARCH.md b/fundlab/RESEARCH.md index 88b9a8f..4a48652 100644 --- a/fundlab/RESEARCH.md +++ b/fundlab/RESEARCH.md @@ -121,14 +121,94 @@ not a gate. stats. ### Next iterations -1. [x] **Add missing factors + cluster by return driver** (this - iteration - fundlab/factors.py + fundlab/cluster.py). -2. N-PORT holdings cross-check on the top ~15 candidates (the v1 - 16-fund pipeline: edgar NPORT fetch + category buckets) to confirm - what the alpha funds actually hold. +1. [x] **Add missing factors + cluster by return driver** + (fundlab/factors.py + fundlab/cluster.py). +2. [x] **N-PORT holdings cross-check on the top candidates** + (fundlab/xcheck.py) - results below. 3. CEF universe (485/N-2 filers) - separate pass; CEFs have premium/discount dynamics the NAV screen can't see. +### N-PORT cross-check (fundlab/xcheck.py, 2026-08-27) +21 of 22 top candidates resolved to their ACTUAL holdings (qcmmrx = +money-market account, no holdings to parse). + +Pipeline hard-won facts: +- The fund's NPORT is usually filed under the fund's OWN registrant + (browse-edgar ticker->CIK), not the 497-cover family/trust CIK + (EV: trust 1552324 vs fund 745463). covers.json of the own CIK gives + the exact series name -> the ONLY reliable disambiguator between + sibling funds ("...Absolute Return Fund" vs "...Advantage Fund" share + 6 of 7 words; token-overlap alone can't tell them apart). +- Big trusts file dozens of NPORT-Ps per quarter (one per fund); a + flat newest-30 window misses the fund's own filing. Take all filings + on the 4 most recent distinct dates. +- The submissions API points at the XSL-RENDERED view + (xslFormNPORT-P_X01/primary_doc.xml, a 5-20MB HTML page); the raw + schema data (seriesName/invstOrSec/netAssets) sits at + accession-root primary_doc.xml (5KB-1.5MB). Strip the xsl prefix. +- The raw file is malformed XHTML (CSS in