f/app.py
Greg Pomerantz 895efc9bff Correct ROC placement: ROC defers to the investor's LTCG, like
appreciation - a taxable-account feature, not an IRA one

Correction after user pushback. The earlier note "ROC does not help
the taxable case - the deferral replicates the IRA" was WRONG: a
traditional IRA defers to the ORDINARY rate at withdrawal, while ROC
in a taxable account defers to the LTCG rate on a >1y sale (the
distribution is basis-reducing and reappears inside the shareholder's
own capital gain). Under the premise LTCG rate < future ordinary
rate, ROC - like NAV appreciation - favors the taxable account.
Contrast ordinary income (interest, ordinary divs, STCG): taxed at
the ordinary rate in BOTH accounts, so only the IRA's deferral wins.

taxplan.py:
- _deferred_share(): per fund, share of 5y total return that defers
  to the investor = (NAV change + ROC) / total return, from the
  parsed per-share N-CSR table, max'd with the taxsplit appreciation
  share.
- >= 50% deferred -> location "TAXABLE (defers to LTCG)" (renamed
  from "TAXABLE (accrues)"); 10-50% ROC in distributions -> note.
- Merger-arb cap 0.35 -> 0.50: HMEZX's per-share table (52% NII /
  30% gains / 18% ROC over 5y) refutes "mostly STCG" - HMEZX/MERVX
  are now MIXED (check 1099), not clean IRA.
- RESEARCH.md: corrected placement write-up, incl. the distinction
  between the tax question and the fund-quality question (heavy ROC
  can mean principal erosion - PGSIX NAV -34%/5y - which affects
  selection, not the optimal account).

App: order dict + filter updated to the new location name.
Tests: merger-arb expectation updated to MIXED; 2 new checks for
the ROC upgrade/note logic. 99/99 fundlab + 32/32 app.
2026-08-27 17:11:55 -04:00

1112 lines
51 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)]
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)
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 = {}
with st.expander(f"Summary — all {len(_FL_LABELS)} funds"):
_sum_rows = []
for _s in _ORDER:
_d = _DR.get(_s, {})
_rec = _d.get("recent", {})
_comp = ", ".join(f"{c['sym']} {c['beta']:+.2f}"
for c in _rec.get("components", [])[:4]) or \
", ".join(f"{c['sym']} {c['beta']:+.2f}"
for c in _d.get("components", [])[:4])
_sum_rows.append({
"fund": _FL_LABELS.get(_s, _s),
"verdict": _d.get("verdict", "n/a"),
"R² 5y": round(_rec["r2"], 3) if "r2" in _rec else None,
"alpha 5y": (f"{_rec['alpha_ann']*100:+.1f}% "
f"(t={_rec['alpha_t']:+.1f})")
if "alpha_ann" in _rec else None,
"components": _comp or "",
})
st.dataframe(pd.DataFrame(_sum_rows), width="stretch")
# --- 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 ""),
"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 vs 21 broad sleeve axes (same "
"set for every fund); 'alpha 5y' = OLS intercept over the "
"last 5 years (t-stat); '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())
_lab = _cl.kmeans(_V, _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.DRIVERS[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.DRIVERS[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 returns are regressed on 35 sleeve axes "
"(equity styles/sizes/intl/EM, the duration ladder, IG/HY/"
"muni/MBS/preferred/EM-debt credit, sectors, gold/oil/"
"commodities, CTA); the 35-d loading vector is the fund's "
"'return-driver signature' and 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; 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.")