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).
This commit is contained in:
Greg Pomerantz 2026-08-30 18:35:42 -04:00
parent e28f8fcf85
commit d2ff607c79
4 changed files with 166 additions and 77 deletions

1
.gitignore vendored
View File

@ -9,3 +9,4 @@ fundlab/nport_cache/raw/
fundlab/universe_cache/
fundlab/xcheck_run.log
fundlab/streamlit.log
reports/report_data_adhoc.json

191
app.py
View File

@ -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, "

View File

@ -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).

View File

@ -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)