- 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
435 lines
18 KiB
Python
435 lines
18 KiB
Python
"""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")
|