f/app.py
Greg Pomerantz 2bbe4e58ec Style-tilt battery + commentary in the fund report pipeline
fundlab/styletilt.py: 22 style/asset sleeves regressed on excess-of-T-bill
returns (full history + 5y); BIC forward selection identifies the tilt
stack; residual-alpha verdict ('factor exposure, not skill' when t<1.75);
data-driven English commentary with sign-specific phrasing. Rendered as a
'Style tilts' block (factor table + prose) in both the app and the HTML
report. All 24 pre-built + 8 ad-hoc fund reports rebuilt.
2026-08-30 20:36:04 -04:00

1323 lines
62 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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}
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
# 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_ann and ann_return_bench only have a value when a "
"benchmark is set. 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, 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,
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.adj, bundle.div, bundle.capg, w, rebalance=scheme,
cost_bps=cost_bps, lt_rate=lt, st_rate=st_r,
div_rate=div, 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, 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, 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%}"
+ (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"])))
bm = (rng(bench["price"]).resample("ME").last()
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))
summaries[f"{name} (after-tax)"] = pick(m.summary(after, bm))
elif show_pre:
summaries[name] = pick(m.summary(pre, bm))
else:
summaries[name] = pick(m.summary(after, bm))
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]}",
"": (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 01: "
"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.")