f/app.py
Greg Pomerantz 54d26939dc Fund Lab: N-PORT holdings page for the 16-fund shortlist
- fundlab/nport.py: parse the fund's own category/percentage lines,
  as-of date, net assets and dollar-valued positions from N-PORT
  schedules of investments (handles per-fund and combined multi-fund
  family filings; conservative keyword bucketing of positions)
- fundlab/nport_cache/<sym>.json: parsed snapshots for 15 of 16 funds
  (raw SOI HTML kept locally, gitignored; source URLs + filing dates in
  nport_manifest.json, md5-verified against EDGAR)
- atesx: no current SOI found (Anchor's recent filings cover the Income
  fund) - listed with an honest note
- app.py: new 'Fund Lab' tab - pick a fund, see objective, reported
  composition (bar + table), rough keyword buckets, top positions, and
  the prospectus strategy excerpt
- tests: parser unit tests (section finding, category regex, buckets)
2026-08-26 09:59:24 -04:00

659 lines
28 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")
_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")
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.")