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:
parent
e28f8fcf85
commit
d2ff607c79
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -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
191
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, "
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user