From d2ff607c793ddaccbec10b7ca333bd17a72065f7 Mon Sep 17 00:00:00 2001 From: Greg Pomerantz Date: Sun, 30 Aug 2026 18:35:42 -0400 Subject: [PATCH] On-demand per-fund reports for standalone Symbol-box funds Any symbol that stands alone in the Symbol box (not a comma-joined portfolio component) now gets a full report, built on demand and cached in reports/report_data_adhoc.json. Rendered at the top of the Fund Lab Summary as group 'A'; pointer at the top of the page. Rendering logic extracted into render_fund_report() shared by pre-built and on-demand entries. ~0.3-0.9 s per new fund after one-time panel warmup; instant afterwards (memory + disk cache). --- .gitignore | 1 + app.py | 191 +++++++++++++++++++++++++----------------- fundlab/RESEARCH.md | 14 ++++ fundlab/reportdata.py | 37 ++++++++ 4 files changed, 166 insertions(+), 77 deletions(-) diff --git a/.gitignore b/.gitignore index 3b2a7d7..431a928 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ fundlab/nport_cache/raw/ fundlab/universe_cache/ fundlab/xcheck_run.log fundlab/streamlit.log +reports/report_data_adhoc.json diff --git a/app.py b/app.py index 3cb02bd..43ced33 100644 --- a/app.py +++ b/app.py @@ -403,6 +403,17 @@ def rng(s): """Clip a price/return series (or frame) to the display range.""" return s[(s.index >= lo) & (s.index <= hi)] +# standalone symbols in the Symbol box (not portfolio components) get a +# full per-fund report, built on demand - point the user at it +_standalone = [s for w in items if len(w) == 1 for s in w] +if _standalone: + st.info( + f"Full report(s) generated for: " + f"{', '.join(s.upper() for s in _standalone)} — see the " + f"**Fund Lab** tab, top of the Summary section. (Only symbols " + f"that stand alone in the Symbol box get a report; portfolio " + f"components do not.)") + tab_stats, tab_equity, tab_alloc, tab_tax, tab_corr, tab_fundlab = st.tabs( ["Statistics", "Equity curves", "Allocation", "Tax detail", "Correlation", "Fund Lab"]) @@ -576,6 +587,87 @@ with tab_corr: # ---------------------------------------------------------------- fund lab # per-fund report mockup from N-PORT schedules of investments (fundlab) + +def render_fund_report(_f: dict) -> None: + """Render one per-fund report entry (narrative, re-basing equity + chart, period table, drivers, reference mix, tax, cluster peers). + Used by the Summary section for both the pre-built 24 and the + on-demand reports generated for standalone Symbol-box funds.""" + _m, _st = _f["meta"], _f.get("stats", {}) + _grp = {"candidate": "C", "shortlist": "S"}.get(_m["group"], "A") + # ad-hoc entries carry negative sort orders (they sort first) - show + # the absolute number; and skip the name when it just repeats the + # ticker (ETFs have no fund name on file) + _num = abs(int(_m["order"])) + _nm = _m["name"] if _m["name"].upper() != _m["sym"].upper() else None + _title = (f"{_grp}{_num:02d} · {_m['sym'].upper()}" + + (f" — {_nm}" if _nm else "")) + if _m.get("verdict"): + _title += f" [{_m['verdict'][:44]}]" + with st.expander(_title): + if _f.get("narrative"): + st.markdown("\n\n".join(_f["narrative"])) + # same re-basing-on-zoom widget as the Equity curves tab: every + # visible window re-bases each line to 1.0 at its left edge, so + # fund/reference/index are comparable no matter where you zoom + _c = _f.get("chart", {}) + if _c.get("dates"): + from chart_widget import equity_chart_html + _ds = pd.to_datetime(_c["dates"]) + _series = {_m["sym"].upper(): pd.Series(_c["fund"], _ds)} + if _c.get("ref"): + _series["fitted reference"] = pd.Series( + _c["ref"], pd.to_datetime(_c["ref_dates"])) + if _c.get("ivv"): + _series["S&P 500 (IVV)"] = pd.Series( + _c["ivv"], pd.to_datetime(_c["ivv_dates"])) + st.iframe(equity_chart_html( + {k: s / s.iloc[0] for k, s in _series.items()}, + _ds, height=460, ytitle="growth (1.0 = start)", + styles={ + _m["sym"].upper(): {"width": 2}, + "fitted reference": {"width": 1, "dash": "dash", + "opacity": 0.8}, + "S&P 500 (IVV)": {"width": 1, "dash": "dot", + "opacity": 0.5}, + }), height=490) + if _f.get("perf"): + st.dataframe(pd.DataFrame( + _f["perf"], + columns=["period", "fund", "reference", + "S&P 500", "fund − reference"]), + width="stretch", hide_index=True) + st.caption( + "fund − reference = period alpha/timing (the part of " + "that period the fitted mix does not explain). For " + "cash-anchored funds the reference is the T-bill rate " + "itself.") + if _f.get("drivers"): + st.markdown("\n\n".join(_f["drivers"])) + _ref = _f.get("reference") + if _ref and _ref.get("rows"): + st.markdown(f"**Reference mix** ({_ref['netcash']})") + st.dataframe(pd.DataFrame( + _ref["rows"], + columns=["loading", "what it is", + "what it exposes you to"]), + width="stretch", hide_index=True) + st.caption(_ref["text"]) + if _f.get("tax"): + st.markdown(f"**Tax** — {_f['tax']}") + _pe = _f.get("peers") + if _pe and _pe.get("rows"): + st.markdown( + f"**Peers — cluster: {_pe['cluster']}** " + f"(n={_pe['n']})") + st.dataframe(pd.DataFrame( + _pe["rows"], + columns=["fund", "5y", "CAGR", "maxDD", + "R² 5y", "alpha 5y", "tax"]), + width="stretch", hide_index=True) + st.caption(_pe["proscons"]) + + with tab_fundlab: try: _FUNDS = json.loads((Path(__file__).parent / "funds.json").read_text()) @@ -606,11 +698,27 @@ with tab_fundlab: try: _rd = json.loads(_RD.read_text()) _rfunds = _rd["funds"] - _rorder = sorted(_rfunds, - key=lambda s: _rfunds[s]["meta"]["order"]) except Exception as _e: # noqa: BLE001 st.warning(f"report data unreadable: {_e}") _rfunds = {} + # on-demand reports: a symbol that stands ALONE in the Symbol box + # (not a component of a comma-joined portfolio) gets a full + # report, so any fund can be examined; results are cached on + # disk (reports/report_data_adhoc.json) so re-loading is instant + from fundlab import reportdata as _rdata + _standalone = [s for w in items if len(w) == 1 for s in w] + _new = [s for s in _standalone if s not in _rfunds] + if _new: + with st.spinner( + f"Building report(s) for {', '.join(s.upper() for s in _new)}" + " (first time for a fresh server: up to ~1 min for the " + "driver panel; cached afterwards)"): + _built = _rdata.ensure_adhoc(_new) + _rfunds = {**_built, **_rfunds} # ad-hoc entries first + for _i, (_s, _f) in enumerate(_built.items(), 1): + _f["meta"]["order"] = -_i # ad-hoc block sorts first + _rorder = sorted(_rfunds, + key=lambda s: _rfunds[s]["meta"]["order"]) if _rfunds: st.subheader(f"Summary - per-fund reports " f"({len(_rfunds)} funds)") @@ -628,7 +736,9 @@ with tab_fundlab: _f = _rfunds[_s] _m, _st = _f["meta"], _f.get("stats", {}) _ag.append({ - "fund": f"{_m['sym'].upper()} — {_m['name'][:44]}", + "fund": (f"{_m['sym'].upper()}" + if _m["name"].upper() == _m["sym"].upper() + else f"{_m['sym'].upper()} — {_m['name'][:44]}"), "group": _m["group"], "5y": _st.get("t5y", "—"), "CAGR": _st.get("cagr", "—"), @@ -640,80 +750,7 @@ with tab_fundlab: st.dataframe(pd.DataFrame(_ag), width="stretch", hide_index=True) for _s in _rorder: - _f = _rfunds[_s] - _m, _st = _f["meta"], _f.get("stats", {}) - _grp = "C" if _m["group"] == "candidate" else "S" - _title = (f"{_grp}{_m['order']:02d} · {_m['sym'].upper()} " - f"— {_m['name']}") - if _m.get("verdict"): - _title += f" [{_m['verdict'][:44]}]" - with st.expander(_title): - if _f.get("narrative"): - st.markdown("\n\n".join(_f["narrative"])) - # same re-basing-on-zoom widget as the Equity curves tab: - # every visible window re-bases each line to 1.0 at its - # left edge, so fund/reference/index are comparable no - # matter where you zoom - _c = _f.get("chart", {}) - if _c.get("dates"): - from chart_widget import equity_chart_html - _ds = pd.to_datetime(_c["dates"]) - _series = {_m["sym"].upper(): - pd.Series(_c["fund"], _ds)} - if _c.get("ref"): - _series["fitted reference"] = pd.Series( - _c["ref"], pd.to_datetime(_c["ref_dates"])) - if _c.get("ivv"): - _series["S&P 500 (IVV)"] = pd.Series( - _c["ivv"], pd.to_datetime(_c["ivv_dates"])) - st.iframe(equity_chart_html( - {k: s / s.iloc[0] - for k, s in _series.items()}, - _ds, height=460, ytitle="growth (1.0 = start)", - styles={ - _m["sym"].upper(): {"width": 2}, - "fitted reference": - {"width": 1, "dash": "dash", - "opacity": 0.8}, - "S&P 500 (IVV)": - {"width": 1, "dash": "dot", - "opacity": 0.5}, - }), height=490) - if _f.get("perf"): - st.dataframe(pd.DataFrame( - _f["perf"], - columns=["period", "fund", "reference", - "S&P 500", "fund − reference"]), - width="stretch", hide_index=True) - st.caption( - "fund − reference = period alpha/timing (the " - "part of that period the fitted mix does not " - "explain). For cash-anchored funds the " - "reference is the T-bill rate itself.") - if _f.get("drivers"): - st.markdown("\n\n".join(_f["drivers"])) - _ref = _f.get("reference") - if _ref and _ref.get("rows"): - st.markdown(f"**Reference mix** ({_ref['netcash']})") - st.dataframe(pd.DataFrame( - _ref["rows"], - columns=["loading", "what it is", - "what it exposes you to"]), - width="stretch", hide_index=True) - st.caption(_ref["text"]) - if _f.get("tax"): - st.markdown(f"**Tax** — {_f['tax']}") - _pe = _f.get("peers") - if _pe and _pe.get("rows"): - st.markdown( - f"**Peers — cluster: {_pe['cluster']}** " - f"(n={_pe['n']})") - st.dataframe(pd.DataFrame( - _pe["rows"], - columns=["fund", "5y", "CAGR", "maxDD", - "R² 5y", "alpha 5y", "tax"]), - width="stretch", hide_index=True) - st.caption(_pe["proscons"]) + render_fund_report(_rfunds[_s]) st.caption("← the rest of the Fund Lab continues below: " "alpha search, return-driver clusters, N-PORT " "cross-check, drawdown resilience, tax location, " diff --git a/fundlab/RESEARCH.md b/fundlab/RESEARCH.md index 0ab20ef..a34b96a 100644 --- a/fundlab/RESEARCH.md +++ b/fundlab/RESEARCH.md @@ -1149,3 +1149,17 @@ first point before handing it to the widget (per-series 1.0 start, as the widget expects). Also removed the "expand all" checkbox (24 expanded sections made the page unusable and hid the sections below) and added a "rest of the Fund Lab continues below" marker. + +**On-demand reports for any Symbol-box fund.** `reportdata.ensure_adhoc(syms)` +builds the same full report entry (narrative, chart, period table, drivers, +reference, tax, peers) for ARBITRARY symbols, cached in memory + on disk +(reports/report_data_adhoc.json, gitignored) so repeat loads are instant and +survive restarts; build time ~0.3-0.9 s per new fund after the one-time +~30-60 s driver-panel warmup. The app triggers it for symbols that stand +ALONE in the Symbol box (a parsed item with a single component) - portfolio +components (comma-joined) are explicitly excluded, per user request. Ad-hoc +entries render at the top of the Fund Lab Summary as group "A" (before the +pre-built 24), the per-fund rendering was extracted into render_fund_report() +shared by both, and a pointer at the top of the page tells the user where +the generated reports are. ETFs with no name on file render as "A01 · TLT" +(no duplicated name). diff --git a/fundlab/reportdata.py b/fundlab/reportdata.py index 49cb284..8cd3db5 100644 --- a/fundlab/reportdata.py +++ b/fundlab/reportdata.py @@ -183,6 +183,10 @@ def _tax(sym: str) -> str: def _peers(sym: str, fr: dict) -> dict: + # no return history: a zero loading vector would park the fund in the + # cash cluster - no peer table for a fund with no measured returns + if _r.price(sym) is None: + return {} if sym in _r.MEMBER: cid, clabel, cn = _r.MEMBER[sym] members = _r.KMEANS["clusters"][cid]["syms"] @@ -272,6 +276,39 @@ def _peers(sym: str, fr: dict) -> dict: "rows": rows, "proscons": pc} +ADHOC_FILE = HERE.parent / "reports" / "report_data_adhoc.json" +_ADHOC_MEM: dict = None # lazy-loaded disk cache + + +def _adhoc_store() -> dict: + global _ADHOC_MEM + if _ADHOC_MEM is None: + try: + _ADHOC_MEM = json.loads(ADHOC_FILE.read_text()) + except Exception: + _ADHOC_MEM = {"funds": {}} + return _ADHOC_MEM + + +def ensure_adhoc(syms: list[str]) -> dict[str, dict]: + """Build report entries for ARBITRARY funds (any symbol the user puts + in the portfolio box), cached in memory + on disk so repeat loads are + instant and survive server restarts. The one-time ~30-60 s driver-panel + build happens on the first call in a fresh process.""" + store = _adhoc_store() + funds = store["funds"] + missing = [s for s in syms + if s not in funds + and _r.price(s) is not None] + if missing: + for s in missing: + funds[s] = build_fund(s, "adhoc", + order=len(funds) + 1) + ADHOC_FILE.parent.mkdir(exist_ok=True) + ADHOC_FILE.write_text(json.dumps(store)) + return {s: funds[s] for s in syms if s in funds} + + def build_fund(sym: str, group: str, order: int) -> dict: t0 = time.time() fr = _r.factor_row(sym)