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