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/universe_cache/
fundlab/xcheck_run.log fundlab/xcheck_run.log
fundlab/streamlit.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.""" """Clip a price/return series (or frame) to the display range."""
return s[(s.index >= lo) & (s.index <= hi)] 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( tab_stats, tab_equity, tab_alloc, tab_tax, tab_corr, tab_fundlab = st.tabs(
["Statistics", "Equity curves", "Allocation", "Tax detail", ["Statistics", "Equity curves", "Allocation", "Tax detail",
"Correlation", "Fund Lab"]) "Correlation", "Fund Lab"])
@ -576,6 +587,87 @@ with tab_corr:
# ---------------------------------------------------------------- fund lab # ---------------------------------------------------------------- fund lab
# per-fund report mockup from N-PORT schedules of investments (fundlab) # 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: with tab_fundlab:
try: try:
_FUNDS = json.loads((Path(__file__).parent / "funds.json").read_text()) _FUNDS = json.loads((Path(__file__).parent / "funds.json").read_text())
@ -606,11 +698,27 @@ with tab_fundlab:
try: try:
_rd = json.loads(_RD.read_text()) _rd = json.loads(_RD.read_text())
_rfunds = _rd["funds"] _rfunds = _rd["funds"]
_rorder = sorted(_rfunds,
key=lambda s: _rfunds[s]["meta"]["order"])
except Exception as _e: # noqa: BLE001 except Exception as _e: # noqa: BLE001
st.warning(f"report data unreadable: {_e}") st.warning(f"report data unreadable: {_e}")
_rfunds = {} _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: if _rfunds:
st.subheader(f"Summary - per-fund reports " st.subheader(f"Summary - per-fund reports "
f"({len(_rfunds)} funds)") f"({len(_rfunds)} funds)")
@ -628,7 +736,9 @@ with tab_fundlab:
_f = _rfunds[_s] _f = _rfunds[_s]
_m, _st = _f["meta"], _f.get("stats", {}) _m, _st = _f["meta"], _f.get("stats", {})
_ag.append({ _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"], "group": _m["group"],
"5y": _st.get("t5y", ""), "5y": _st.get("t5y", ""),
"CAGR": _st.get("cagr", ""), "CAGR": _st.get("cagr", ""),
@ -640,80 +750,7 @@ with tab_fundlab:
st.dataframe(pd.DataFrame(_ag), width="stretch", st.dataframe(pd.DataFrame(_ag), width="stretch",
hide_index=True) hide_index=True)
for _s in _rorder: for _s in _rorder:
_f = _rfunds[_s] render_fund_report(_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"])
st.caption("← the rest of the Fund Lab continues below: " st.caption("← the rest of the Fund Lab continues below: "
"alpha search, return-driver clusters, N-PORT " "alpha search, return-driver clusters, N-PORT "
"cross-check, drawdown resilience, tax location, " "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 the widget expects). Also removed the "expand all" checkbox (24 expanded
sections made the page unusable and hid the sections below) and added a sections made the page unusable and hid the sections below) and added a
"rest of the Fund Lab continues below" marker. "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: 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: if sym in _r.MEMBER:
cid, clabel, cn = _r.MEMBER[sym] cid, clabel, cn = _r.MEMBER[sym]
members = _r.KMEANS["clusters"][cid]["syms"] members = _r.KMEANS["clusters"][cid]["syms"]
@ -272,6 +276,39 @@ def _peers(sym: str, fr: dict) -> dict:
"rows": rows, "proscons": pc} "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: def build_fund(sym: str, group: str, order: int) -> dict:
t0 = time.time() t0 = time.time()
fr = _r.factor_row(sym) fr = _r.factor_row(sym)