diff options
| -rw-r--r-- | app.py | 146 | ||||
| -rw-r--r-- | components/watchlist.py | 126 |
2 files changed, 242 insertions, 30 deletions
@@ -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) |
