aboutsummaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
Diffstat (limited to 'components')
-rw-r--r--components/watchlist.py126
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)