"""Streamlit UI for stock/portfolio analysis. Run: streamlit run app.py """ from __future__ import annotations import json import re from pathlib import Path import pandas as pd import plotly.graph_objects as go import streamlit as st import metrics as m from data import DEFAULT_ROOT, generation, load_bundle, search_symbols, up_to_date from fundlab import nport from portfolio import parse_items, parse_weights, portfolio_returns from portfolios import Portfolio, delete as pf_delete, load_all as pf_load, save as pf_save from tax import after_tax_portfolio st.set_page_config(page_title="Stock & Portfolio Analyzer", layout="wide") # ---------------------------------------------------------------- settings # user inputs persist across reloads / server restarts / devices SETTINGS_FILE = Path(__file__).parent / "settings.json" _settings: dict = {} try: _settings = json.loads(SETTINGS_FILE.read_text()) except Exception: pass def _remember(**kw) -> None: """Persist current widget values (write only on change).""" global _settings changed = False for k, v in kw.items(): if _settings.get(k) != v: _settings[k] = v changed = True if changed: try: SETTINGS_FILE.write_text(json.dumps(_settings, indent=2)) except Exception: pass # ---------------------------------------------------------------- sidebar st.sidebar.title("Stock & Portfolio Analyzer") root = st.sidebar.text_input("Data root", str(DEFAULT_ROOT)) rebuild = st.sidebar.checkbox("Rebuild parquet cache", value=False) with st.spinner("Loading data..."): bundle = load_bundle(root=Path(root), rebuild=rebuild) if not up_to_date(Path(root)): st.caption("Data files changed since the last cache refresh — a background " "thread is updating the cache; figures below may be a few minutes stale.") # data symbols are stored lowercase; input is resolved case-insensitively _LOWER2SYM = {s.lower(): s for s in bundle.adj.columns} # risk-free rate for the statistics page: BIL (SPDR 1-3 Month T-Bill) # daily total return — the same convention as the Fund Lab (alphas in # excess of the 3-mo T-bill, cash earns exactly the rf rate) _bil = _LOWER2SYM.get("bil") rf = m.daily_returns(bundle.adj[_bil]) if _bil is not None else None def _resolve_weights(w: dict) -> dict: """Map parsed symbols onto the data's symbol case (insensitive match).""" return {_LOWER2SYM.get(s.lower(), s): v for s, v in w.items()} def _replace_token(spec: str, old: str, new: str) -> str: """Replace the symbol entry `old` (with or without a ':weight') by `new`. Grammar: spaces separate items, commas join elements of one item. """ def fix_item(item: str) -> str: out = [] for p in item.split(","): sym = p.rsplit(":", 1)[0] if sym.lower() == old.lower(): p = new if ":" not in p else new + p.rsplit(":", 1)[1] out.append(p) return ",".join(out) return " ".join(fix_item(it) for it in spec.split()) st.sidebar.subheader("Portfolio") # hand-off from e.g. 'Load saved into field': must be applied before the # spec widget is (re)instantiated _handover = st.session_state.pop("spec_handover", None) if _handover is not None: st.session_state["spec"] = _handover spec = st.sidebar.text_input( "Symbol(s) or portfolio(s)", value=_settings.get("spec", ""), key="spec", help=("One or more, space-separated — same grammar as the Benchmark " "field.\n" "MSFT <- single symbol\n" "MSFT:0.6,V:0.4 <- portfolio (commas join its elements, bare " "symbols are equal-weight)\n" "MSFT V googl:0.5,amzn:0.5 <- three separate entries, each " "analyzed on its own\n" "The FIRST entry is the 'current' portfolio: it drives the tax " "detail tab, the allocation tab and Save.")) saved = pf_load() if saved: st.sidebar.subheader("Saved portfolios") lcol1, lcol2 = st.sidebar.columns([3, 1]) load_name = lcol1.selectbox("Load into field", [p.name for p in saved], key="load_name") if lcol2.button("Load"): s = next(p.spec for p in saved if p.name == load_name) # old specs used ', ' between elements; that would now read as two items st.session_state["spec_handover"] = s.replace(", ", ",") st.rerun() _scheme_labels = ["Buy & hold (drift)", "Weekly", "Monthly", "Quarterly", "Yearly"] scheme = st.sidebar.selectbox( "Rebalance scheme", _scheme_labels, index=min(int(_settings.get("scheme_index", 3)), len(_scheme_labels) - 1)) freq = { "Buy & hold (drift)": None, "Weekly": "1W", "Monthly": "1ME", "Quarterly": "QE", "Yearly": "YE", }[scheme] cost_bps = st.sidebar.number_input("Trading cost (bps, one-way)", 0.0, 100.0, float(_settings.get("cost_bps", 5.0))) st.sidebar.subheader("Tax (after-tax analysis)") lt_rate = st.sidebar.number_input("Long-term gains %", 0.0, 49.0, float(_settings.get("lt_rate", 20.0))) / 100 st_rate = st.sidebar.number_input("Short-term gains %", 0.0, 49.0, float(_settings.get("st_rate", 15.0))) / 100 div_rate = st.sidebar.number_input("Dividends %", 0.0, 49.0, float(_settings.get("div_rate", 15.0))) / 100 niit = st.sidebar.number_input( "NIIT % (federal 3.8% on investment income if MAGI over the " "threshold; 0 otherwise)", 0.0, 5.0, float(_settings.get("niit", 0.0))) / 100 sl_rate = st.sidebar.number_input( "State + local % (NY+NYC: capital gains taxed at ORDINARY rates, " "no preferential cap-gain rate)", 0.0, 49.0, float(_settings.get("sl_rate", 0.0))) / 100 # statistics available in the Statistics tab (names = metrics.summary keys) _STATS_VALID = ("total_return", "return", "vol", "sharpe", "sortino", "max_dd", "calmar", "beta", "alpha", "ann_return_bench") _STATS_DEFAULT = ",".join(_STATS_VALID) # older saved settings may still use the long names _STATS_RENAME = {"ann_return": "return", "ann_vol": "vol", "alpha_ann": "alpha"} st.sidebar.subheader("Period & benchmark") _p = int(_settings.get("period", 2010)) period = st.sidebar.selectbox("From year", range(2002, 2026), index=(min(max(_p, 2002), 2025) - 2002)) bench_spec = st.sidebar.text_input( "Benchmark (optional)", value=_settings.get("bench_spec", ""), key="bench_spec", help=("Same grammar as the symbol field: a single symbol (MSFT) or a " "portfolio (MSFT:0.6,V:0.4 — commas join its elements).\n" "Spaces separate DISTINCT benchmarks, e.g.\n" "MSFT MSFT:0.6,V:0.4\n" "Empty = none. Simulated with the same rebalance scheme, cost and " "tax rules. Beta/alpha in the stats table refers to the first " "benchmark.")) st.sidebar.subheader("Statistics") stats_order = st.sidebar.text_input( "Statistics (comma-separated, in display order)", value=_settings.get("stats_order", _STATS_DEFAULT), key="stats_order", help=("Which statistics to show on the Statistics tab, in the order they " "appear.\n" "Available: " + ", ".join(_STATS_VALID) + "\n" "beta, alpha and ann_return_bench only have a value when a " "benchmark is set. Sharpe, Sortino and alpha are computed in " "excess of the 3-mo T-bill (BIL daily total return), as in the " "Fund Lab. Empty field = show all, default order.")) _stats_cols = [_STATS_RENAME.get(s.strip(), s.strip()) for s in stats_order.split(",") if s.strip()] _unknown_stats = [s for s in _stats_cols if s not in _STATS_VALID] if _unknown_stats: st.sidebar.warning(f"Unknown statistic(s) ignored: {', '.join(_unknown_stats)}") _stats_cols = [s for s in _stats_cols if s in _STATS_VALID] if not _stats_cols: _stats_cols = list(_STATS_VALID) st.sidebar.subheader("Date range (display, all tabs)") _win_labels = ["Max", "10Y", "5Y", "3Y", "1Y", "YTD", "3M", "1M"] _dw = _settings.get("date_window", _settings.get("equity_window", "Max")) win = st.sidebar.radio("Window", _win_labels, horizontal=True, key="date_window", index=_win_labels.index(_dw) if _dw in _win_labels else 0, help=("Standard window for the START of the display " "range, counted back from the end. The window " "applies to every tab.\n" "Max = from the first data point.")) def _parse_date(s: str): """YYYYMMDD (or YYYY-MM-DD) -> Timestamp, '' -> None, garbage -> 'bad'.""" s = s.strip() if not s: return None for fmt in ("%Y%m%d", "%Y-%m-%d"): try: return pd.to_datetime(s, format=fmt) except Exception: pass return "bad" range_start = st.sidebar.text_input( "Start (YYYYMMDD, blank = window start)", value=_settings.get("range_start", ""), key="range_start", help="Absolute start date, e.g. 20230102 = Jan 2, 2023. Overrides the " "Window. Blank = the Window decides.") range_end = st.sidebar.text_input( "End (YYYYMMDD, blank = latest data)", value=_settings.get("range_end", ""), key="range_end", help="Absolute end date, e.g. 20230102 = Jan 2, 2023. Blank = latest " "available data.") _start_ts = _parse_date(range_start) _end_ts = _parse_date(range_end) if _start_ts == "bad": st.sidebar.warning(f"Invalid start date '{range_start.strip()}' " "(use YYYYMMDD) — ignored.") _start_ts = None if _end_ts == "bad": st.sidebar.warning(f"Invalid end date '{range_end.strip()}' " "(use YYYYMMDD) — ignored.") _end_ts = None # remember the sidebar inputs now, before any validation st.stop() _remember(spec=spec, bench_spec=bench_spec, scheme_index=_scheme_labels.index(scheme), cost_bps=cost_bps, lt_rate=lt_rate * 100, st_rate=st_rate * 100, div_rate=div_rate * 100, niit=niit * 100, sl_rate=sl_rate * 100, period=period, stats_order=stats_order, date_window=win, range_start=range_start, range_end=range_end) if not spec.strip(): st.info("Type symbols or portfolios in the sidebar — e.g. `MSFT` or " "`MSFT:0.6,V:0.4`; space-separated entries are each analyzed " "separately. The page updates as soon as the input is valid.") st.stop() try: items = [_resolve_weights(w) for w in parse_items(spec)] except ValueError as e: st.error(f"Invalid input: {e}") st.stop() weights = items[0] # first entry = the 'current' portfolio all_syms = {s for w in items for s in w} unknown = [s for s in all_syms if s not in bundle.adj.columns] if unknown: st.error(f"Symbol field — not in the data: {', '.join(unknown)}") for u in unknown[:2]: cands = [s for s in search_symbols(bundle, u, limit=4) if s not in all_syms] if cands: st.caption(f"Did you mean {u}:") cols = st.sidebar.columns(2) for i, s in enumerate(cands): if cols[i % 2].button(s, key=f"fix-{u}-{s}", use_container_width=True): st.session_state["spec_handover"] = _replace_token(spec, u, s) st.rerun() st.stop() start = f"{period}-01-01" use_syms = list(weights) # ---- save / delete / select saved portfolios ------------------------- st.sidebar.subheader("Saved portfolios") if len(items) == 1: pname = st.sidebar.text_input("Name to save current portfolio as", key="save_name") if st.sidebar.button("Save current portfolio"): pname = pname.strip() if pname: # commas without spaces: under the spec grammar that stays ONE portfolio spec_str = ",".join(f"{s}:{w:g}" for s, w in weights.items()) pf_save(Portfolio(pname, spec_str, freq, cost_bps)) st.sidebar.success(f"Saved '{pname}'.") st.rerun() else: st.sidebar.warning("Give it a name first (field below).") else: st.sidebar.caption("Save: the Symbol field must hold exactly ONE " "portfolio to save (it would save the first entry).") compare_names: list[str] = [] if saved: compare_names = st.sidebar.multiselect( "Compare on the chart", [p.name for p in saved], default=[p.name for p in saved]) dcol1, dcol2 = st.sidebar.columns([3, 1]) del_name = dcol1.text_input("Delete by name", key="del_name") if dcol2.button("Delete") and del_name.strip(): pf_delete(del_name.strip()) st.rerun() # ------------------------------------------------------------ compute # cache the heavy simulations: switching the chart window / curve toggle / tax # rates only rebuilds the (cheap) HTML — the portfolio+tax runs happen once @st.cache_data(show_spinner=False) def _compute_portfolio(w_key: tuple, scheme: str | None, cost_bps: float, start: str, lt: float, st_r: float, div: float, n: float, sl: float, root: str, gen: int): w = dict(w_key) r = portfolio_returns(bundle.adj, w, rebalance=scheme, cost_bps=cost_bps, start=start) t = after_tax_portfolio(bundle.close, bundle.div, bundle.capg, w, rebalance=scheme, cost_bps=cost_bps, lt_rate=lt, st_rate=st_r, div_rate=div, niit=n, sl_rate=sl, start=start) return r, t def build_result(name: str, w: dict, scheme: str | None, cost: float) -> dict: r, t = _compute_portfolio(tuple(sorted(w.items())), scheme, cost, start, lt_rate, st_rate, div_rate, niit, sl_rate, str(root), generation(Path(root))) return {"name": name, "weights": w, "res": r, "tax": t} results = [] for k, w in enumerate(items): name = "Current" if len(items) == 1 else ", ".join(w) results.append(build_result(name, w, freq, cost_bps)) for p in saved: if p.name not in compare_names: continue try: w = {s: v for s, v in parse_weights(p.spec).items() if s in bundle.adj.columns} if not w: st.sidebar.warning(f"'{p.name}': no known symbols, skipped.") continue results.append(build_result(p.name, w, p.scheme, p.cost_bps)) except Exception as e: st.sidebar.warning(f"'{p.name}': {e}") res, taxres = results[0]["res"], results[0]["tax"] # benchmark: same spec format as the main field, simulated with the same # rebalance scheme and cost; invalid/unknown input just disables it # benchmarks: ';' separates independent specs; each is simulated exactly # like the candidate portfolio (same scheme, cost and tax rules) # each item (line, or space-separated on a line) is one benchmark benchmarks: list[dict] = [] for i, item in enumerate(bench_spec.split(), 1): try: w = _resolve_weights(parse_weights(item)) except ValueError as e: st.sidebar.warning(f"Benchmark {i} ignored: {e}") continue unknown = [s for s in w if s not in bundle.adj.columns] if unknown: st.sidebar.warning(f"Benchmark {i} ignored: not in the data: " f"{', '.join(unknown)}") continue try: r, t = _compute_portfolio(tuple(sorted(w.items())), freq, cost_bps, start, lt_rate, st_rate, div_rate, niit, sl_rate, str(root), generation(Path(root))) except ValueError as e: st.sidebar.warning(f"Benchmark {i} ignored: {e}") continue benchmarks.append({ "label": ", ".join(w), "price": r.equity.reindex(res.equity.index).ffill(), "after": t.equity.reindex(res.equity.index).ffill(), }) # first benchmark is the reference for beta/alpha in the stats table bench_price = benchmarks[0]["price"] if benchmarks else None bench_after = benchmarks[0]["after"] if benchmarks else None bench_label = benchmarks[0]["label"] if benchmarks else None # ------------------------------------------------------------ display if len(items) == 1: if len(use_syms) == 1: st.title(f"{use_syms[0]} — single symbol") else: st.title(f"Portfolio: {', '.join(use_syms)}") else: st.title("Portfolios: " + " · ".join(", ".join(w) for w in items)) if len(results) > 1: st.caption(f"Comparing: {', '.join(r['name'] for r in results)}") st.caption(f"{scheme} · start {start} · cost {cost_bps} bps · " f"tax LT/ST/div {lt_rate:.0%}/{st_rate:.0%}/{div_rate:.0%} " + (" · Sharpe/Sortino/alpha in excess of the 3-mo T-bill (BIL)" if rf is not None else " · no T-bill (BIL) in the data — " "Sharpe/Sortino/alpha unadjusted") + (f" · benchmark: {' ; '.join(b['label'] for b in benchmarks)}" if benchmarks else "")) # global curve mode — applies to the stats table AND the chart _mode_labels = ["Pre-tax", "After-tax", "Pre-tax + after-tax"] mode = st.radio("Curve", _mode_labels, horizontal=True, key="curve_mode", index=_mode_labels.index(_settings["curve_mode"]) if _settings.get("curve_mode") in _mode_labels else 0) show_pre, show_after, both = mode != "After-tax", mode != "Pre-tax", \ mode == "Pre-tax + after-tax" # ------------------------------------------------------------ display range # one global [lo, hi] window applied to every tab (display only — the # simulation itself still starts at 'From year'). Start: the Start box wins, # else the Window counted back from the end. End: the End box, else latest. _data_idx = res.equity.index _data_start, _data_end = _data_idx[0], _data_idx[-1] hi = _end_ts if _end_ts is not None else _data_end if _start_ts is not None: lo = _start_ts elif win == "YTD": lo = pd.Timestamp(hi.year, 1, 1) elif win != "Max": unit = "years" if win.endswith("Y") else "months" lo = hi - pd.DateOffset(**{unit: int(win[:-1])}) else: lo = _data_start if lo > hi or len(_data_idx[(_data_idx >= lo) & (_data_idx <= hi)]) < 2: st.sidebar.warning("Date range covers no (or one) data point — " "showing the full range.") lo, hi = _data_start, _data_end 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"]) with tab_stats: # one table per benchmark: same rows, but beta/alpha/bench-return are # measured against THAT benchmark (no benchmarks -> single table # without those columns). One row per candidate (the whole portfolio, # not its components); the pre/after suffix only appears in 'both' mode. candidates = [(r["name"], r["res"].equity, r["tax"].equity) for r in results] def bench_block(bench: dict | None) -> None: rows = [(n, rng(p), rng(a)) for n, p, a in candidates] if bench is not None: rows.append((f"benchmark: {bench['label']}", rng(bench["price"]), rng(bench["after"]))) # daily price series — metrics.summary() computes daily returns # and annualizes with 252 days/yr, so never pre-resample bm = rng(bench["price"]) if bench is not None else None def pick(d: dict) -> dict: # user-configured selection + order; skip columns the summary # lacks (e.g. beta without a benchmark) out = {} for k in _stats_cols: if k in d: out[k] = d[k] return out summaries = {} for name, pre, after in rows: if both: summaries[f"{name} (pre-tax)"] = pick(m.summary(pre, bm, rf)) summaries[f"{name} (after-tax)"] = pick(m.summary(after, bm, rf)) elif show_pre: summaries[name] = pick(m.summary(pre, bm, rf)) else: summaries[name] = pick(m.summary(after, bm, rf)) st.dataframe(m.format_summary_table(summaries), width='stretch') if benchmarks: for b in benchmarks: st.subheader(f"vs {b['label']}") bench_block(b) else: bench_block(None) with tab_equity: # Self-contained plotly.js page (see chart_widget.py): full mouse zoom + # pan, with every relayout instantly clamping x to the data and re-fitting # y to the exact min/max of the visible data. No clipping, no blank space. from chart_widget import equity_chart_html _remember(curve_mode=mode) idx = rng(res.equity).index # labels carry the pre/after suffix only in 'both' mode palette = ["#1f77b4", "#ff7f0e", "#2ca02c", "#d62728", "#9467bd", "#17becf", "#e377c2", "#8c564b"] def lab(name, which): return f"{name} — {which}" if both else name series: dict[str, pd.Series] = {} styles: dict[str, dict] = {} for k, r in enumerate(results): color = palette[k % len(palette)] if show_pre: series[lab(r["name"], "pre-tax")] = rng(r["res"].equity) styles[lab(r["name"], "pre-tax")] = {"color": color, "width": 2} if show_after: s = lab(r["name"], "after-tax") series[s] = rng(r["tax"].equity) # faded only in 'both' mode, to stay distinct from the pre-tax line styles[s] = {"color": color, "width": 2, "opacity": 0.55 if both else 1.0} for b in benchmarks: base = f"benchmark ({b['label']})" if show_pre: p = rng(b["price"]) s = lab(base, "pre-tax") series[s] = p / p.iloc[0] styles[s] = {"width": 1, "opacity": 0.6} if show_after: p = rng(b["after"]) s = lab(base, "after-tax") series[s] = p / p.iloc[0] styles[s] = {"width": 1, "opacity": 0.3 if both else 0.6} html = equity_chart_html(series, idx, ytitle="growth (1.0 = start)", styles=styles) st.iframe(html, height=600) def final(name, pre, after): if both: return f"{name}: pre {pre:.2f}× / after {after:.2f}×" return f"{name}: {(pre if show_pre else after):.2f}×" finals = " · ".join(final(r["name"], rng(r["res"].equity).iloc[-1], rng(r["tax"].equity).iloc[-1]) for r in results) for b in benchmarks: finals += " · " + final(f"benchmark ({b['label']})", rng(b["price"]).iloc[-1], rng(b["after"]).iloc[-1]) st.caption(f"Final: {finals}. " "Drag to box-zoom, right-drag (or mode-bar hand) to pan, " "scroll to zoom, double-click to reset.") with tab_alloc: st.caption(f"Allocation for the **{results[0]['name']}** portfolio " "(first entry in the Symbol field).") alloc = rng(res.allocation) st.dataframe(alloc.tail(1).T, width='stretch') fig = go.Figure(go.Bar(x=alloc.columns, y=alloc.iloc[-1])) fig.update_layout(height=360, title=f"Drifted allocation @ {alloc.index[-1].date()}") st.plotly_chart(fig, width='stretch') st.caption(f"Final allocation: " + ", ".join(f"{k} {v:.1%}" for k, v in alloc.iloc[-1].items())) with tab_tax: st.caption(f"Tax detail for the **{results[0]['name']}** portfolio " "(first entry in the Symbol field).") # all values below are fractions of the starting account (1.0 = 100%) yr_tax = rng(taxres.taxes).resample("YE").sum() yr_tax.index = yr_tax.index.year yr_tax = yr_tax.map(lambda v: f"{v:.2%}") yr_real = rng(taxres.realized).resample("YE").sum() yr_real.index = yr_real.index.year yr_real = yr_real.map(lambda v: f"{v:+.2%}") yr_liq = rng(taxres.liq_tax).resample("YE").last() yr_liq.index = yr_liq.index.year yr_liq = yr_liq.map(lambda v: f"{v:.2%}") st.subheader("Taxes actually paid per year (distributions + rebalance sales)") st.dataframe(yr_tax, width='stretch') st.subheader("Realized gains/losses per year (at rebalances)") st.dataframe(yr_real, width='stretch') st.subheader("Unrealized-gain tax you would owe if you sold everything (year-end)") st.dataframe(yr_liq, width='stretch') _tax_total = rng(taxres.taxes)["total"].sum() _final_after = rng(taxres.equity).iloc[-1] st.caption(f"Total taxes paid to date: {_tax_total:.2%} of start · " f"final after-tax value (sell everything): {_final_after:.2f}× " f"= {_final_after - 1:+.1%} total return") with tab_corr: # cross-correlation (daily returns, pairwise over common history): # per portfolio, its components vs the benchmarks; then all portfolios # vs the benchmarks def corr_block(title: str, series: dict[str, pd.Series]) -> None: if len(series) < 2: return st.subheader(title) tbl = m.xcorr(series) # the table is symmetric: row names double as column names, so the # columns are just numbered 1..n (column i = row i) tbl.columns = range(1, len(tbl.columns) + 1) st.dataframe(tbl, width='stretch') for r in results: if len(r["weights"]) < 2: continue # single component: it already has a row in the # all-portfolios table below series = {s: rng(bundle.adj[s]) for s in r["weights"]} series.update({f"benchmark: {b['label']}": rng(b["price"]) for b in benchmarks}) corr_block(f"{r['name']} — components vs benchmarks", series) series = {r["name"]: rng(r["res"].equity) for r in results} series.update({f"benchmark: {b['label']}": rng(b["price"]) for b in benchmarks}) corr_block("All portfolios vs benchmarks", series) st.caption("Pearson correlation of daily returns; pairs correlate over their " "common history. Benchmarks are prefixed with 'benchmark: '. " "The table is symmetric — column number i is the series of row i.") # ---------------------------------------------------------------- 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"])) _sty = _f.get("style") if _sty and _sty.get("full"): _f5 = {x["sym"]: x for x in _sty.get("rec5", {}).get("factors", [])} _srows = [] for x in _sty["full"]["factors"]: y5 = _f5.get(x["sym"], {}) if abs(x["t"]) < 2 and abs(y5.get("t", 0) or 0) < 2: continue _srows.append([ x["name"], f"{x['beta']:+.2f}", f"{x['t']:+.1f}", (f"{y5['beta']:+.2f}" if y5 else "—"), (f"{y5['t']:+.1f}" if y5 else "—"), "✓" if x["sym"] in _sty.get("identified", []) else ""]) st.markdown("**Style tilts** (22 style/asset sleeves regressed " "on the fund's excess returns; US funds proxy the " "global factors; 'identified' = survived the BIC " "forward-selection gate on the full history)") st.dataframe(pd.DataFrame( _srows, columns=["factor", "β full", "t full", "β 5y", "t 5y", "identified"]), width="stretch", hide_index=True) st.markdown("\n\n".join(_sty.get("commentary", []))) _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()) except Exception: _FUNDS = {} _MAN = nport.manifest() _ORDER = list(nport.FUND_TOKENS) _FL_LABELS = {s: f"{s.upper()} — {_FUNDS[s]['name']}" for s in _ORDER if s in _FUNDS} _fl_pick = st.selectbox("Fund (shortlist)", list(_FL_LABELS), format_func=lambda s: _FL_LABELS[s], key="fundlab_pick") # --- all-funds summary table ------------------------------------- from fundlab import decompose as _dc try: _DR = json.loads(_dc.RESULTS.read_text()) except Exception: _DR = {} # --- per-fund report: narrative + performance + drivers + peers ---- # static build (fundlab.reportdata) so the page stays fast _RD = Path(__file__).parent / "reports" / "report_data.json" if not _RD.exists(): with st.expander("Summary - per-fund reports (not built yet)"): st.info("Run `.venv/bin/python -m fundlab.reportdata` in the " "project root, then refresh.") else: try: _rd = json.loads(_RD.read_text()) _rfunds = _rd["funds"] 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)") st.caption( f"Generated {_rd['generated']}. Each fund: a narrative " "discussion first (the performance, what drives it, and " "what we do NOT know), then the equity curve, the period " "table (fund vs fitted reference vs S&P 500, with the " "fund-minus-reference gap), the return drivers, the " "reference mix explained sleeve by sleeve, tax placement, " "and the best peers from the same return-driver cluster. " "All alphas are in excess of the 3-mo T-bill rate.") _ag = [] for _s in _rorder: _f = _rfunds[_s] _m, _st = _f["meta"], _f.get("stats", {}) _ag.append({ "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", "—"), "maxDD": _st.get("mdd", "—"), "R² 5y": _st.get("r2_5y", "—"), "alpha 5y": _st.get("alpha_5y", "—"), "cluster": (_f.get("peers") or {}).get("cluster", ""), }) st.dataframe(pd.DataFrame(_ag), width="stretch", hide_index=True) for _s in _rorder: 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, " "and the CEF ranking.") # --- alpha search: all screened funds (shortlist + longlist + harvest) with st.expander("Alpha search — all screened funds, ranked"): # search_all (comprehensive overnight screen) is the superset; # read it first so its rows win any sym collision. _by_sym: dict[str, dict] = {} for _src in ("search_all.json", "search_results.json", "search_mined.json", "search_external.json"): try: _j = json.loads((_dc.RESULTS.parent / _src).read_text()) except Exception: continue for _k, _v in _j.items(): if isinstance(_v, dict) and _v.get("sym"): _v.setdefault("source", _src) _by_sym.setdefault(_v["sym"], _v) _all_rows = list(_by_sym.values()) if _all_rows: def _tier(r) -> int: v = str(r.get("verdict", "")) if v.startswith("CANDIDATE"): return 0 if "correlated" in v: return 1 if "not persistent" in v: return 2 if "sleeve" in v: return 3 if "weak" in v: return 4 return 5 _all_rows.sort(key=lambda r: (_tier(r), -(r.get("alpha_t_5y") if isinstance(r.get("alpha_t_5y"), (int, float)) else -9))) _tbl = [] for _v in _all_rows: _a5 = _v.get("alpha_ann_5y") _tbl.append({ "fund": f"{_v['sym'].upper()} — {_v.get('name', '')[:50]}", "bucket": _v.get("bucket", ""), "R² 5y": (f"{_v['r2_5y']:.2f}" if isinstance(_v.get("r2_5y"), (int, float)) else "—"), "alpha 5y": (f"{_a5*100:+.1f}% (t={_v['alpha_t_5y']:+.1f})" if isinstance(_a5, (int, float)) else "—"), "reference (5y)": _v.get("ref_5y") or "—", "corr port": (f"{_v['corr_portfolio']:.2f}" if isinstance(_v.get("corr_portfolio"), (int, float)) else "—"), "6m + %": (f"{_v['alpha_pos_frac']:.0%}" if isinstance(_v.get("alpha_pos_frac"), (int, float)) else "—"), "verdict": _v.get("verdict", _v.get("error", "")), }) st.dataframe(pd.DataFrame(_tbl), width="stretch") st.caption( "Screen: daily total returns IN EXCESS OF THE 3-MO T-BILL " "RATE (BIL) vs 21 broad sleeve axes (same set for every " "fund); 'alpha 5y' = OLS intercept of the excess returns " "over the last 5 years (t-stat) — i.e. outperformance vs " "the fund's OWN fitted sleeve mix (the 'reference' column), " "not vs one index; a cash position earns exactly the t-bill " "rate and adds zero alpha. 'corr port' = correlation with " "your current qspnx/pmaix portfolio; '6m + %' = share of " "rolling 6-month windows where the fund beat its fitted " "sleeve mix. CANDIDATE = R²5y < 0.6 (or < 0.85 with strong " "residual alpha), t5y ≥ 2, t-full ≥ 1.25, ≥ 45% positive " "windows, portfolio correlation < 0.3.") # --- return-driver clusters: k-means on 35-sleeve loading vectors --- with st.expander( "Return-driver clusters — funds grouped by what drives " "their returns"): _ck = st.slider("clusters", 10, 60, 30, step=2, key="fl_clus_k") try: import numpy as _np from fundlab import cluster as _cl from fundlab import factors as _fac _syms, _V = _cl.loading_matrix() _fr = json.loads((_dc.RESULTS.parent / "factor_results.json").read_text()) # distance on the cash-emphasized matrix (a pure cash fund's # level axis is otherwise swallowed by the low-exposure cloud); # labels below use the raw _V values _lab = _cl.kmeans(_cl.emphasized(_V, _fac.AXES.index("cash")), _ck) _sumrows = [] for c in range(_ck): idx = [i for i, l in enumerate(_lab) if l == c] if not idx: continue med = _np.median(_V[idx], 0) top = _np.argsort(-_np.abs(med))[:4] prof = {_fac.AXES[t]: float(med[t]) for t in top if abs(med[t]) >= 0.08} lab = _cl.label(prof) t5s = [_fr[_syms[i]].get("alpha_t_5y") for i in idx] t5s = [t for t in t5s if isinstance(t, (int, float))] n_sig = sum(1 for t in t5s if t >= 2) best = max(idx, key=lambda i: abs( _fr[_syms[i]].get("alpha_t_5y") or 0)) _sumrows.append({ "cluster": lab, "n": len(idx), "t5≥2": n_sig, "top by |t5|": (f"{_syms[best].upper()} " f"({_fr[_syms[best]].get('name','')[:40]})")}) # remember cluster id with each summary row for the selectbox for _i, _r in enumerate(_sumrows): _r["_id"] = _i _sumrows.sort(key=lambda r: -r["n"]) st.dataframe(pd.DataFrame( {k: r[k] for k in ("cluster", "n", "t5≥2", "top by |t5|")} for r in _sumrows), width="stretch") _cpick = st.selectbox( "expand a cluster", [f"{r['cluster']} (n={r['n']}, t5≥2: {r['t5≥2']})" for r in _sumrows], key="fl_clus_pick") _wanted = _cpick.split(" (")[0] _crows = [r for r in _sumrows if r["cluster"] == _wanted] _cn = (_crows[0]["n"] if _crows else None) _crows = [r for r in _sumrows if r["cluster"] == _wanted and r["n"] == _cn] _target = _crows[0]["cluster"] if _crows else None _members = [] for c in range(_ck): idx = [i for i, l in enumerate(_lab) if l == c] if not idx: continue med = _np.median(_V[idx], 0) top = _np.argsort(-_np.abs(med))[:4] prof = {_fac.AXES[t]: float(med[t]) for t in top if abs(med[t]) >= 0.08} if _cl.label(prof) == _target: _members = [(i, idx[0]) for i in idx] break _rows = [] for i, _b in _members: v = _fr.get(_syms[i], {}) _a5 = v.get("alpha_ann_5y") _t5 = (v.get("alpha_t_5y") if isinstance(v.get("alpha_t_5y"), (int, float)) else -99) _rows.append({ "fund": f"{_syms[i].upper()} — {v.get('name','')[:48]}", "R²": (f"{v['full']['r2']:.2f}" if isinstance(v.get("full"), dict) else "—"), "alpha 5y": (f"{_a5*100:+.1f}% (t={_t5:+.1f})" if isinstance(_a5, (int, float)) else "—"), "corr port": (f"{v['corr_portfolio']:.2f}" if isinstance( v.get("corr_portfolio"), (int, float)) else "—"), "verdict": (v.get("verdict") or "")[:40], "src": "local" if v.get("local") else "new", "_t": _t5, }) _rows.sort(key=lambda r: -r["_t"]) for r in _rows: r.pop("_t") st.dataframe(pd.DataFrame(_rows), width="stretch") st.caption( "Each fund's daily total returns IN EXCESS OF THE 3-MO " "T-BILL RATE (BIL) are regressed on 34 sleeve axes (equity " "styles/sizes/intl/EM, the duration ladder, IG/HY/muni/MBS/" "preferred/EM-debt credit, sectors, gold/oil/commodities, " "CTA); the loading vector is the fund's 'return-driver " "signature' — the betas are its NET INVESTED mix — plus a " "virtual CASH axis = 1 − Σβ (net cash/T-bill position; 1 = " "fully in cash, < 0 = levered). k-means groups similar " "signatures. 'corr port' is shown for reference only - " "high-corr funds are REPLACEMENTS for current holdings, " "not rejections.") 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") # ---- drawdown resilience: who was positive when equities crashed ---- with st.expander( "Drawdown resilience - who was positive when equities " "crashed"): _DDF = _dc.RESULTS.parent / "drawdown_results.json" if not _DDF.exists(): st.info("No drawdown screen on file yet " "(run `python -m fundlab.drawdown`).") else: _d = json.loads(_DDF.read_text()) _ep = _d["episodes"] st.dataframe(pd.DataFrame([ {"scenario": e["label"], "peak": e["peak"], "trough": e["trough"], "index drop": f"{e['min_dd']*100:.1f}%"} for e in _ep]), width="stretch") st.caption( "Funds' total return over each peak->trough window " "(their own NAV, first print after the peak to the " "trough). Scenarios are detected from the index, not " "hard-coded. Sorted by # scenarios positive.") _df = _d["funds"] _cands = {s: v for s, v in _df.items() if v["verdict"].startswith("CANDIDATE")} _rows = [] for s, v in _cands.items(): if v["n_pos"] < 3: continue _rows.append({ "fund": s.upper(), "name": v["name"][:44], **{e["label"]: (f"{v['rets'][e['label']]*100:+.1f}%" if e["label"] in v["rets"] else "n/a") for e in _ep}, "# pos": f"{v['n_pos']}/{v['n_avail']}", "worst": f"{v['min_ret']*100:+.1f}%", "corr port": (f"{v['corr_port']:+.2f}" if isinstance(v.get("corr_port"), (int, float)) else "—"), "alpha t5": (f"{v['t5']:+.1f}" if isinstance(v.get("t5"), (int, float)) else "—"), "_k": (v["n_pos"], v["n_avail"], v["min_ret"]), }) _rows.sort(key=lambda r: (-r["_k"][0], -r["_k"][1], r["_k"][2])) for r in _rows: r.pop("_k") st.dataframe(pd.DataFrame(_rows), width="stretch") st.caption( "Note: being positive in every equity drawdown is mostly " "a duration property - the 5/5 group is all " "ultra-short/cash. The interesting rows are the alpha " "funds with 4/5 (merger arb, market-neutral, " "securitized credit) that still earned their 5y alpha.") # ---- tax location: taxable account vs IRA ------------------------- with st.expander( "Tax location - which account (taxable vs IRA) for each fund"): _TP = _dc.RESULTS.parent / "taxplan_results.json" if not _TP.exists(): st.info("No tax-location screen on file yet " "(run `python -m fundlab.taxplan`).") else: _tp = json.loads(_TP.read_text()) st.caption( "Where each fund's distributions should live, given the " "current LTCG rate < future ordinary rate. Basis: N-PORT " "holdings (16 shortlist + 22 cross-checked) or return-" "sleeve proxy (250 candidates). 'score' = estimated share " "of distributions that are tax-favorable (qualified " "dividends + LTCG + tax-exempt). MIXED funds: pull the " "last 1099-DIV - it is the final arbiter.") _sp = {} _SPF = _dc.RESULTS.parent / "taxsplit_results.json" if _SPF.exists(): _spj = json.loads(_SPF.read_text()) for _grp in _spj.values(): for _s, _v in _grp.items(): if _v: _sp.setdefault(_s.upper(), _v) def _tp_table(funds: dict) -> pd.DataFrame: rows = [] for s, r in sorted(funds.items()): _v = _sp.get(s.upper()) rows.append({ "fund": s, "name": r["name"][:44], "location": r["location"], "5y price": (f"{_v['price']*100:+.0f}%" if _v else ""), "5y payout": (f"{_v['payout_a']*100:.1f}%/yr" if _v else ""), "12m payout": (f"{_v['payout_12m']*100:.1f}%" if _v else ""), "score": r["score"], "basis": r["basis"], "notes": r["notes"][:120], }) order = {"TAXABLE": 0, "TAXABLE (defers to LTCG)": 1, "TAXABLE (munis)": 2, "MIXED (check 1099)": 3, "IRA": 4, "FLEXIBLE (cash)": 5, "NO DATA": 9} df = pd.DataFrame(rows) df["_o"] = df["location"].map( lambda x: order.get(x, 5)) df = df.sort_values(["_o", "score"], ascending=[True, False]) return df.drop(columns="_o") st.markdown("**16-fund shortlist**") st.dataframe(_tp_table(_tp["shortlist"]), width="stretch") st.markdown("**22 cross-checked**") st.dataframe(_tp_table(_tp["xcheck"]), width="stretch") _sel = st.selectbox( "Candidates (250)", ["All locations", "TAXABLE", "TAXABLE (defers to LTCG)", "TAXABLE (munis)", "MIXED (check 1099)", "IRA", "FLEXIBLE (cash)"], key="_tp_loc") _c = _tp["candidates"] if _sel != "All locations": _c = {s: r for s, r in _c.items() if r["location"] == _sel} st.dataframe(_tp_table(_c), width="stretch") st.caption( "Reading the table: TAXABLE = income is mostly qualified " "dividends/LTCG (or tax-exempt) - the taxable account's " "low LTCG rate is the benefit. IRA = ordinary interest / " "STCG / non-qualified - deferral is the benefit. FLEXIBLE " "= cash, no placement value either way. Note the merger-" "arb trap: equity-looking books that distribute mostly " "SHORT-TERM gains (HMEZX, MERVX) belong in the IRA.") _f = _FUNDS.get(_fl_pick, {}) _man = _MAN.get(_fl_pick, {}) st.subheader(f"{_f.get('name', _fl_pick)} · {_fl_pick.upper()}") st.write(_f.get("objective") or "_no objective on file_") _src_bits = [] if _f.get("category"): _src_bits.append(f"category: **{_f['category']}**") if _man.get("filed"): _src_bits.append(f"N-PORT filed {_man['filed']}") if _man.get("url"): _src_bits.append(f"[source filing]({_man['url']})") if _src_bits: st.caption(" · ".join(_src_bits)) snap = nport.build(_fl_pick, nport.FUND_TOKENS[_fl_pick]) if snap is None: st.info("Holdings not available: " + (_man.get("note") or "no cached N-PORT filing for this fund.")) else: c1, c2, c3, c4 = st.columns(4) c1.metric("As of", snap.get("as_of") or "?") c2.metric("Net assets", f"${snap['net_assets']:,.0f}" if snap.get("net_assets") else "?") c3.metric("Positions parsed", f"{snap['n_positions']}") c4.metric("Categories", f"{len(snap['categories'])}") lc, rc = st.columns([3, 2]) with lc: st.markdown("**Reported composition** — the fund's own " "category/percentage lines, in filing order") if snap["categories"]: cats = pd.DataFrame(snap["categories"]) fig = go.Figure(go.Bar( x=cats["pct"].head(20)[::-1], y=cats["name"].head(20)[::-1], orientation="h", marker_color="#4c78a8")) fig.update_layout(height=380, margin=dict(l=0, r=0, t=8, b=8), xaxis_title="% of net assets") st.plotly_chart(fig, width="stretch") with st.expander("All category lines"): st.dataframe(cats, width="stretch") else: st.caption("No category lines could be parsed from this filing.") with rc: st.markdown("**Rough keyword buckets** — my coarse parse of the " "position lines, *not* the fund's own classification") if snap["buckets"]: bks = pd.DataFrame(snap["buckets"]) bks["value"] = bks["value"].map(lambda v: f"${v:,.0f}") st.dataframe(bks, width="stretch") else: st.caption("No position values could be parsed.") if snap["top"]: with st.expander(f"Top positions ({len(snap['top'])})"): top = pd.DataFrame(snap["top"]) top["value"] = top["value"].map(lambda v: f"${v:,.0f}") st.dataframe(top, width="stretch") # ---- returns-based strategy decomposition ------------------------- st.markdown("**Strategy decomposition** — which benchmark sleeves explain " "the fund's returns (OLS forward selection, BIC-gated, " "|t|>2; full history + last 5 years)") _dr = _DR.get(_fl_pick, {}) if not _dr or "verdict" not in _dr: st.info("No decomposition available for this fund.") elif "r2" not in _dr: st.info(_dr["verdict"]) if _dr.get("note"): st.markdown("**Holdings cross-check:** " + _dr["note"]) else: _rec = _dr.get("recent", {}) _v = _dr["verdict"] if _v.startswith("static") or _v.startswith("mostly stable"): st.success(_v) elif "no component" in _v or "not a static" in _v: st.warning(_v) else: st.info(_v) _c = st.columns(6) _c[0].metric("R² full", f"{_dr.get('r2', float('nan')):.3f}") _c[1].metric("R² 5y", f"{_rec['r2']:.3f}" if "r2" in _rec else "n/a") _c[2].metric("alpha 5y", f"{_rec['alpha_ann']*100:+.1f}% (t={_rec['alpha_t']:+.1f})" if "alpha_ann" in _rec else "n/a") _c[3].metric("trk err 5y", f"{_rec['tracking_err_ann']*100:.1f}%" if "tracking_err_ann" in _rec else "n/a") _c[4].metric("max β-drift", f"{_dr.get('rolling', {}).get('max_drift', 0.0):.2f}") _c[5].metric("sample", f"{_dr['n_obs']}d", help=f"{_dr['start']} .. {_dr['end']}") _rows = [] _full_b = {c["sym"]: c for c in _dr.get("components", [])} _rec_b = {c["sym"]: c for c in _rec.get("components", [])} for _sym in list(dict.fromkeys(list(_full_b) + list(_rec_b))): _frow = _full_b.get(_sym) _rrow = _rec_b.get(_sym) _rows.append({ "component": f"{_sym} — {_frow['label'] if _frow else _rrow['label']}", "β full": f"{_frow['beta']:+.2f} (t={_frow['t']:+.1f})" if _frow else "—", "β 5y": f"{_rrow['beta']:+.2f} (t={_rrow['t']:+.1f})" if _rrow else "—", }) if _rows: st.dataframe(pd.DataFrame(_rows), width="stretch") if _full_b: _bar = pd.DataFrame([ {"component": f"{s} — {c['label']}", "full": c["beta"]} for s, c in _full_b.items()]) fig = go.Figure(go.Bar( x=_bar["full"], y=_bar["component"], orientation="h", marker_color=["#2ca02c" if v >= 0 else "#d62728" for v in _bar["full"]])) fig.update_layout(height=30 + 24 * len(_bar), margin=dict(l=0, r=0, t=8, b=8), xaxis_title="β (full sample)") st.plotly_chart(fig, width="stretch") if _dr.get("note"): st.markdown("**Holdings cross-check:** " + _dr["note"]) st.caption( "Method: daily total returns IN EXCESS OF THE 3-MO T-BILL RATE " "(BIL); greedy forward selection on BIC (add only if ΔBIC ≥ 2 and " "|t| > 2); candidate sleeves curated per fund from the strategy " "text + N-PORT. β = exposure, not a literal " "holding weight; 'drift' = mean |rolling 1y β − full β| relative to " "the full β. High-R² + low drift ≈ static mix; low R² with positive " "alpha ≈ market-neutral/alpha strategy.") if _f.get("strategy"): st.markdown("**Strategy (excerpt from the prospectus)**") st.write(_f["strategy"]) st.caption( "Mockup: category lines are taken verbatim from the filing and " "parsed heuristically (each family formats its schedule " "differently), so expect the odd mis-parse. Positions are only " "counted where the layout exposes a dollar value; the bucket " "table is a rough keyword classification. N-PORT holdings are " "quarterly and up to ~60 days stale.") # --- closed-end funds (tax-arb, actual distribution character) ------ import pandas as _pd _fl_dir = Path(__file__).parent / "fundlab" _rank_path = _fl_dir / "cef_rank_all.json" if _rank_path.exists(): _rank = json.loads(_rank_path.read_text()) _univ = json.loads((_fl_dir / "cef_universe.json").read_text()) _rows = [] for _score, _s, _ch, _t5, _vol, _npos, _tend, _disc in _rank: _rows.append({ "sym": _s, "name": (_univ.get(_s, {}).get("name") or "")[:44], "char": _ch, "disc%": None if _disc is None else round(100 * _disc, 1), "t5%": None if _t5 is None else round(100 * _t5, 1), "vol5%": None if _vol is None else round(100 * _vol, 1), "pos_scen": _npos, "tenders": _tend, "score": _score}) _cef_df = _pd.DataFrame(_rows) st.divider() st.subheader("Closed-end funds — tax-arb ranking") st.dataframe(_cef_df, width="stretch", height=460) st.caption( f"{len(_cef_df)} CEFs with VERIFIED per-share distribution " "character (divs + gains + ROC == total distributions per year, " "plus the NAV chain, from the fund's own report). char 0–1: " "0 = all ordinary income, 1 = all capital-gains/ROC (taxable " "income that defers to the LTCG/ROC rate in a taxable account). " "score = char × (5y total return + 0.4 × 5y vol): harvestable " "character weighted by how much damage a forced sale could do. " "tenders = N-23C repurchase/tender filings (sponsor capital-" "management pressure). disc% = FY-end market vs NAV.")