aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTyler <tyler@tylerhoang.xyz>2026-05-16 01:30:34 -0700
committerTyler <tyler@tylerhoang.xyz>2026-05-16 01:30:34 -0700
commitb321614ef1aabf3ce001fea471e45bebc35ccb86 (patch)
tree10daf4834abd9cfdfa220fccddc5d1beb0dd32fd
parent658ed53544243a5efe08a6440a1297d521357f2c (diff)
Add session-scoped personal watchlist to sidebar
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 <noreply@anthropic.com>
-rw-r--r--app.py146
-rw-r--r--components/watchlist.py126
2 files changed, 242 insertions, 30 deletions
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;
}
</style>
""", 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('<div class="psm-watch-toggle">', 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("</div>", 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(
"<div class='psm-header-band'>"
"<div class='psm-ticker-head'>"
- "<div style='display:flex;align-items:baseline;gap:0.75rem;flex-wrap:wrap;'>"
+ "<div class='psm-th-left'>"
+ "<div class='psm-th-meta'>"
+ ("<span class='sector'>" + _sector_e + "</span>" if _sector_e else "")
- + "<span class='sym'>" + _sym_e + "</span>"
- "<span class='name'>" + _name_e + "</span>"
+ + "<span class='name'>" + _name_e + "</span>"
+ "</div>"
+ "<span class='sym'>" + _sym_e + "</span>"
"</div>"
"<div class='psm-range'>"
+ "<span class='lbl'>52-wk</span>"
"<span class='edge'>" + _lo_s + "</span>"
"<div class='rail'><div class='pin' style='left:" + str(round(_range_pct, 1)) + "%'></div></div>"
"<span class='edge'>" + _hi_s + "</span>"
@@ -824,11 +906,15 @@ st.markdown(
"</div>"
"<div class='psm-kpis'>"
"<div class='psm-kpi'><span class='k'>Market Cap</span><span class='v'>" + _mktcap + "</span></div>"
- "<div class='psm-kpi'><span class='k'>P/E (TTM)</span><span class='v'>" + _pe + "</span></div>"
- "<div class='psm-kpi'><span class='k'>EPS (TTM)</span><span class='v'>" + _eps + "</span></div>"
+ "<div class='psm-kpi'><span class='k'>P / E</span><span class='v'>" + _pe + "</span></div>"
+ "<div class='psm-kpi'><span class='k'>EPS · LTM</span><span class='v'>" + _eps + "</span>"
+ + ("<span class='sub'>" + _eps_sub + "</span>" if _eps_sub else "")
+ + "</div>"
"<div class='psm-kpi'><span class='k'>Div Yield</span><span class='v'>" + _div + "</span></div>"
"<div class='psm-kpi'><span class='k'>Beta</span><span class='v'>" + _beta + "</span></div>"
- "<div class='psm-kpi'><span class='k'>Short %</span><span class='v'>" + _short + "</span></div>"
+ "<div class='psm-kpi'><span class='k'>Short Int.</span><span class='v'>" + _short + "</span>"
+ + ("<span class='sub'>" + _short_sub + "</span>" if _short_sub else "")
+ + "</div>"
"</div>"
"</div>",
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("""
+<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)