diff options
Diffstat (limited to 'components/watchlist.py')
| -rw-r--r-- | components/watchlist.py | 126 |
1 files changed, 126 insertions, 0 deletions
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(""" +<style> +/* ── Watchlist section label ───────────────────────────────────────────── */ +.psm-watch-label { + font-family: var(--font-sans); + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--fg-4); + margin: 0.5rem 0 0.25rem; +} + +/* ── Watchlist row button overrides ────────────────────────────────────── */ +[data-testid="stSidebar"] .psm-watch-btn button { + background: transparent !important; + border: none !important; + border-bottom: 1px solid var(--line-1) !important; + border-radius: 0 !important; + padding: 0.3rem 0 !important; + width: 100% !important; + text-align: left !important; + cursor: pointer !important; + display: flex !important; + align-items: center !important; +} + +[data-testid="stSidebar"] .psm-watch-btn button:hover { + background: var(--ink-2) !important; +} + +[data-testid="stSidebar"] .psm-watch-btn button p { + font-family: var(--font-mono) !important; + font-size: 12px !important; + color: var(--fg-1) !important; + margin: 0 !important; + line-height: 1.3 !important; +} + +/* ── Watchlist empty state ──────────────────────────────────────────────── */ +.psm-watch-empty { + font-family: var(--font-sans); + font-size: 11px; + color: var(--fg-4); + padding: 0.25rem 0 0.5rem; + font-style: italic; +} +</style> +""", 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('<div class="psm-watch-label">Watchlist</div>', unsafe_allow_html=True) + + if not watchlist: + st.markdown( + '<div class="psm-watch-empty">No saved tickers</div>', + 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('<div class="psm-watch-btn">', 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("</div>", unsafe_allow_html=True) + + except Exception: + # Never let one broken ticker blow up the whole watchlist + sym_safe = escape_html(sym) + st.markdown( + '<div class="psm-watch-btn">', + 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("</div>", unsafe_allow_html=True) |
