From b321614ef1aabf3ce001fea471e45bebc35ccb86 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 16 May 2026 01:30:34 -0700 Subject: Add session-scoped personal watchlist to sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a save/remove toggle below the company snapshot, a watchlist section (capped at 10 tickers) that renders sym · price · Δ% rows above Top Movers, and an empty-state placeholder. Clicking a watchlist row loads that ticker. Co-Authored-By: Claude Sonnet 4.6 --- app.py | 146 ++++++++++++++++++++++++++++++++++++++---------- components/watchlist.py | 126 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 242 insertions(+), 30 deletions(-) create mode 100644 components/watchlist.py diff --git a/app.py b/app.py index b5f2735..b0a9453 100644 --- a/app.py +++ b/app.py @@ -404,6 +404,31 @@ hr { ::-webkit-scrollbar-thumb { background: var(--ink-3); border-radius: 3px; } ::-webkit-scrollbar-thumb:hover { background: var(--ink-4); } +/* ── Watch toggle button ────────────────────────────────────────────────── */ +[data-testid="stSidebar"] .psm-watch-toggle button { + background: var(--ink-2) !important; + color: var(--fg-3) !important; + border: 1px solid var(--line-2) !important; + border-radius: 2px !important; + font-family: var(--font-sans) !important; + font-size: 0.6875rem !important; + font-weight: 500 !important; + letter-spacing: 0.06em !important; + text-transform: uppercase !important; + padding: 3px 8px !important; + margin-top: 6px !important; +} + +[data-testid="stSidebar"] .psm-watch-toggle button:hover { + background: var(--ink-3) !important; + border-color: var(--line-3) !important; + color: var(--fg-1) !important; +} + +[data-testid="stSidebar"] .psm-watch-toggle button p { + color: inherit !important; +} + /* ── Ticker Header Band ──────────────────────────────────────────────────── */ .psm-header-band { margin-bottom: 1rem; @@ -411,27 +436,37 @@ hr { padding-bottom: 0.75rem; } .psm-ticker-head { - display: flex; align-items: center; gap: 1.5rem; - flex-wrap: wrap; margin-bottom: 0.5rem; + display: flex; align-items: flex-end; gap: 2rem; + flex-wrap: nowrap; margin-bottom: 0.5rem; +} +.psm-th-left { + display: flex; flex-direction: column; gap: 0; flex-shrink: 0; +} +.psm-th-meta { + display: flex; align-items: baseline; gap: 0.6rem; margin-bottom: 0.15rem; } .psm-ticker-head .sector { - font-family: var(--font-sans); font-size: 11px; + font-family: var(--font-sans); font-size: 10px; color: var(--brass); text-transform: uppercase; - letter-spacing: 0.1em; + letter-spacing: 0.12em; } -.psm-ticker-head .sym { +.psm-ticker-head .name { font-family: var(--font-display); font-style: italic; - font-size: 2rem; color: var(--fg-1); line-height: 0.95; - letter-spacing: -0.025em; + font-size: 1rem; color: var(--fg-2); line-height: 1.2; } -.psm-ticker-head .name { - font-family: var(--font-sans); font-size: 13px; - color: var(--fg-3); +.psm-ticker-head .sym { + font-family: var(--font-display); font-style: normal; font-weight: 400; + font-size: 3.75rem; color: var(--fg-1); line-height: 0.88; + letter-spacing: -0.02em; } .psm-range { display: flex; align-items: center; gap: 0.5rem; font-family: var(--font-mono); font-size: 11px; color: var(--fg-3); - flex: 1; min-width: 180px; max-width: 280px; + flex: 1; min-width: 200px; max-width: 320px; + align-self: center; +} +.psm-range .lbl { + color: var(--fg-4); white-space: nowrap; } .psm-range .rail { flex: 1; height: 3px; background: var(--line-2); @@ -441,10 +476,11 @@ hr { position: absolute; top: -3px; width: 2px; height: 9px; background: var(--brass); border-radius: 1px; } -.px-block { margin-left: auto; text-align: right; } +.px-block { margin-left: auto; text-align: right; flex-shrink: 0; align-self: flex-start; } .px-block .px { - font-family: var(--font-mono); font-size: 1.5rem; - color: var(--fg-1); display: block; + font-family: var(--font-display); font-size: 2.75rem; + color: var(--fg-1); display: block; line-height: 1.0; + letter-spacing: -0.02em; } .px-block .chg { font-family: var(--font-mono); font-size: 13px; } .px-block .chg.pos { color: var(--positive); } @@ -455,9 +491,10 @@ hr { gap: 0; } .psm-kpi { - padding: 0.35rem 0.75rem 0.35rem 0; + padding: 0.4rem 0.75rem; border-right: 1px solid var(--line-1); } +.psm-kpi:first-child { padding-left: 0; } .psm-kpi:last-child { border-right: none; } .psm-kpi .k { font-family: var(--font-sans); font-size: 10px; @@ -465,8 +502,12 @@ hr { letter-spacing: 0.08em; display: block; } .psm-kpi .v { - font-family: var(--font-mono); font-size: 13px; - color: var(--fg-1); display: block; margin-top: 2px; + font-family: var(--font-mono); font-size: 1.15rem; + color: var(--fg-1); display: block; margin-top: 3px; +} +.psm-kpi .sub { + font-family: var(--font-sans); font-size: 10px; + color: var(--fg-4); display: block; margin-top: 2px; } """, unsafe_allow_html=True) @@ -520,6 +561,7 @@ pio.templates.default = "prism" from components.market_bar import render_market_bar from components.top_movers import render_top_movers +from components.watchlist import render_watchlist from components.overview import render_overview from components.financials import render_financials from components.valuation import render_valuation @@ -533,6 +575,9 @@ from services.data_service import get_company_info, search_tickers, get_latest_p if "ticker" not in st.session_state: st.session_state["ticker"] = None +if "watchlist" not in st.session_state: + st.session_state["watchlist"] = [] + # ── Sidebar ─────────────────────────────────────────────────────────────────── @@ -713,6 +758,26 @@ with st.sidebar: elif ticker: st.caption(f"Viewing: **{ticker}**") + # ── Save / Remove watchlist toggle ──────────────────────────────────── + if ticker: + in_watch = ticker in st.session_state["watchlist"] + label = "— Remove from watchlist" if in_watch else "+ Save to watchlist" + st.markdown('
', unsafe_allow_html=True) + if st.button(label, key="watch_toggle", use_container_width=True): + if in_watch: + st.session_state["watchlist"].remove(ticker) + else: + if ( + ticker not in st.session_state["watchlist"] + and len(st.session_state["watchlist"]) < 10 + ): + st.session_state["watchlist"].append(ticker) + st.rerun() + st.markdown("
", unsafe_allow_html=True) + + # ── Watchlist ────────────────────────────────────────────────────────── + render_watchlist() + st.divider() render_top_movers(compact=True) @@ -781,6 +846,12 @@ def _fmt_ratio(v, d=2): except (TypeError, ValueError): return "—" +def _fmt_plain(v, d=2): + try: + return f"{float(v):.{d}f}" + except (TypeError, ValueError): + return "—" + def _fmt_pct(v): try: return f"{float(v) * 100:.2f}%" @@ -790,29 +861,40 @@ def _fmt_pct(v): _sym_e = escape_html(ticker) _name_e = escape_html(_hdr_info.get("longName") or _hdr_info.get("shortName") or ticker) _sector_e = escape_html(_hdr_info.get("sector") or "") -_price_s = f"${_hdr_price:,.2f}" if _hdr_price else "—" -_lo_s = f"${_lo52:,.2f}" if _lo52 else "—" -_hi_s = f"${_hi52:,.2f}" if _hi52 else "—" +_price_s = f"{_hdr_price:,.2f}" if _hdr_price else "—" +_lo_s = f"{_lo52:,.2f}" if _lo52 else "—" +_hi_s = f"{_hi52:,.2f}" if _hi52 else "—" +_sign = "+" if (_chg_pct or 0) >= 0 else "-" _chg_s = ( - f"{_chg_arrow} ${abs(_chg_abs):,.2f} ({abs(_chg_pct):.2f}%)" + f"{_chg_arrow} {abs(_chg_abs):,.2f} ({_sign}{abs(_chg_pct):.2f}%)" if (_chg_abs is not None and _chg_pct is not None) else "—" ) + _mktcap = _fmt_large(_hdr_info.get("marketCap")) -_pe = _fmt_ratio(_hdr_info.get("trailingPE")) -_eps = _fmt_ratio(_hdr_info.get("trailingEps")) +_pe = _fmt_ratio(_hdr_info.get("trailingPE"), 1) +_raw_eps = _hdr_info.get("trailingEps") +_eps = ("$" + _fmt_plain(_raw_eps, 2)) if _raw_eps else "—" _div = _fmt_pct(_hdr_info.get("dividendYield")) -_beta = _fmt_ratio(_hdr_info.get("beta")) +_beta = _fmt_plain(_hdr_info.get("beta"), 2) _short = _fmt_pct(_hdr_info.get("shortPercentOfFloat")) +_fwd_eps = _hdr_info.get("forwardEps") +_eps_sub = ("fwd $" + _fmt_plain(_fwd_eps, 2)) if _fwd_eps else "" +_short_rat = _hdr_info.get("shortRatio") +_short_sub = (f"{float(_short_rat):.1f} d to cover") if _short_rat else "" + st.markdown( "
" "
" - "
" + "
" + "
" + ("" + _sector_e + "" if _sector_e else "") - + "" + _sym_e + "" - "" + _name_e + "" + + "" + _name_e + "" + "
" + "" + _sym_e + "" "
" "
" + "52-wk" "" + _lo_s + "" "
" "" + _hi_s + "" @@ -824,11 +906,15 @@ st.markdown( "
" "
" "
Market Cap" + _mktcap + "
" - "
P/E (TTM)" + _pe + "
" - "
EPS (TTM)" + _eps + "
" + "
P / E" + _pe + "
" + "
EPS · LTM" + _eps + "" + + ("" + _eps_sub + "" if _eps_sub else "") + + "
" "
Div Yield" + _div + "
" "
Beta" + _beta + "
" - "
Short %" + _short + "
" + "
Short Int." + _short + "" + + ("" + _short_sub + "" if _short_sub else "") + + "
" "
" "
", unsafe_allow_html=True, diff --git a/components/watchlist.py b/components/watchlist.py new file mode 100644 index 0000000..0b50d3b --- /dev/null +++ b/components/watchlist.py @@ -0,0 +1,126 @@ +"""Watchlist sidebar component — session-scoped personal watchlist.""" +import streamlit as st +from services.data_service import get_latest_price, get_company_info +from utils.security import escape_html + +_WATCHLIST_CSS_INJECTED = False + + +def _inject_css(): + global _WATCHLIST_CSS_INJECTED + if _WATCHLIST_CSS_INJECTED: + return + _WATCHLIST_CSS_INJECTED = True + st.markdown(""" + +""", unsafe_allow_html=True) + + +def render_watchlist(): + """Render the personal watchlist section in the sidebar. + + Displays a section label, then one clickable row per saved ticker showing + symbol · price · Δ%. Clicking a row sets st.session_state["ticker"] and + triggers a rerun. Shows an empty-state message when the list is empty. + + Must be called from within a `with st.sidebar:` block. + """ + _inject_css() + + watchlist: list[str] = st.session_state.get("watchlist", []) + + st.markdown('
Watchlist
', unsafe_allow_html=True) + + if not watchlist: + st.markdown( + '
No saved tickers
', + unsafe_allow_html=True, + ) + return + + for sym in watchlist: + try: + price = get_latest_price(sym) + info = get_company_info(sym) or {} + prev = info.get("regularMarketPreviousClose") or info.get("previousClose") + + if price is not None and prev and prev > 0: + chg_pct = (price - prev) / prev * 100 + sign = "+" if chg_pct >= 0 else "" + chg_color = "#4F8C5E" if chg_pct >= 0 else "#B5494B" + row_label = ( + escape_html(sym) + + " $" + f"{price:,.2f}" + + " " + sign + f"{chg_pct:.2f}%" + ) + elif price is not None: + chg_color = "#8E8676" + row_label = escape_html(sym) + " $" + f"{price:,.2f}" + else: + chg_color = "#8E8676" + row_label = escape_html(sym) + " —" + + # Render a styled container div then the Streamlit button inside it + st.markdown('
', unsafe_allow_html=True) + if st.button(row_label, key="watch_row_" + sym, use_container_width=True): + st.session_state["ticker"] = sym + st.rerun() + st.markdown("
", unsafe_allow_html=True) + + except Exception: + # Never let one broken ticker blow up the whole watchlist + sym_safe = escape_html(sym) + st.markdown( + '
', + unsafe_allow_html=True, + ) + if st.button(sym_safe + " (error)", key="watch_row_" + sym, use_container_width=True): + st.session_state["ticker"] = sym + st.rerun() + st.markdown("
", unsafe_allow_html=True) -- cgit v1.3-2-g0d8e