f/app.py
Greg Pomerantz d8703a7a63 Stock & Portfolio Analyzer: full UI rework
- single spec grammar for symbol and benchmark fields: commas join one
  portfolio (MSFT:0.6,V:0.4), spaces separate distinct symbols/portfolios;
  both fields accept one or many entries
- benchmarks simulated with the same scheme/cost/tax rules; per-benchmark
  beta/alpha columns; after-tax benchmark curves
- global Curve mode (pre/after/both) above the tabs; clean names in
  single-curve mode
- live updates: field commits on Enter/blur, page recomputes per rerun;
  portfolio+tax sims cached (st.cache_data); plotly.js from CDN (4.6MB ->
  browser-cached) with F_INLINE_PLOTLY=1 offline fallback
- chart: legend underneath, solid lines, pan sticks to data edges
  (width-preserving), zoom edge-clamped
- inputs persist in settings.json across reloads/restarts/devices
- tests: tests/test_app.py (AppTest) + tests/test_e2e_browser.py
  (Playwright) via ./run_tests.sh
2026-08-24 16:05:27 -04:00

435 lines
18 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, load_bundle, search_symbols
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)
# 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
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."))
# 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)
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):
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))
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))
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"
tab_stats, tab_equity, tab_alloc, tab_tax = st.tabs(
["Statistics", "Equity curves", "Allocation", "Tax detail"])
with tab_stats:
# one beta/alpha/bench-return column SET per benchmark
bench_ms = [(b["label"], b["price"].resample("ME").last()) for b in benchmarks]
def summarize(price):
out = m.summary(price, None)
for blab, bm in bench_ms:
d = m.summary(price, bm)
sfx = "" if len(bench_ms) == 1 else f" [{blab}]"
out[f"beta{sfx}"] = d["beta"]
out[f"alpha_ann{sfx}"] = d["alpha_ann"]
out[f"ann_return_bench{sfx}"] = d["ann_return_bench"]
return out
# one row per candidate (the whole portfolio, not its components) and
# per benchmark; the pre/after suffix only appears in 'both' mode
summaries = {}
def row(name, pre, after):
if both:
summaries[f"{name} (pre-tax)"] = summarize(pre)
summaries[f"{name} (after-tax)"] = summarize(after)
elif show_pre:
summaries[name] = summarize(pre)
else:
summaries[name] = summarize(after)
for r in results:
row(r["name"], r["res"].equity, r["tax"].equity)
for b in benchmarks:
row(f"benchmark: {b['label']}", b["price"], b["after"])
st.dataframe(m.format_summary_table(summaries), width='stretch')
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
_win_labels = ["Max", "10Y", "5Y", "3Y", "1Y"]
win = st.radio("Window", _win_labels,
horizontal=True, key="equity_window",
index=_win_labels.index(_settings["equity_window"])
if _settings.get("equity_window") in _win_labels else 0)
_remember(curve_mode=mode, equity_window=win)
idx = res.equity.index
if win != "Max":
idx = idx[idx >= idx[-1] - pd.DateOffset(years=int(win[:-1]))]
# 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")] = r["res"].equity
styles[lab(r["name"], "pre-tax")] = {"color": color, "width": 2}
if show_after:
s = lab(r["name"], "after-tax")
series[s] = 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:
s = lab(base, "pre-tax")
series[s] = b["price"] / b["price"].iloc[0]
styles[s] = {"width": 1, "opacity": 0.6}
if show_after:
s = lab(base, "after-tax")
series[s] = b["after"] / b["after"].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"], r["res"].equity.iloc[-1],
r["tax"].equity.iloc[-1]) for r in results)
for b in benchmarks:
finals += " · " + final(f"benchmark ({b['label']})", b["price"].iloc[-1],
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 = 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 = taxres.taxes.resample("YE").sum()
yr_tax.index = yr_tax.index.year
yr_tax = yr_tax.map(lambda v: f"{v:.2%}")
yr_real = taxres.realized.resample("YE").sum()
yr_real.index = yr_real.index.year
yr_real = yr_real.map(lambda v: f"{v:+.2%}")
yr_liq = 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')
st.caption(f"Total taxes paid to date: {taxres.taxes['total'].sum():.2%} of start · "
f"final after-tax value (sell everything): {taxres.equity.iloc[-1]:.2f}× "
f"= {taxres.equity.iloc[-1] - 1:+.1%} total return")