diff options
| author | Tyler <tyler@tylerhoang.xyz> | 2026-05-17 00:40:22 -0700 |
|---|---|---|
| committer | Tyler <tyler@tylerhoang.xyz> | 2026-05-17 00:40:22 -0700 |
| commit | 2ea1abbe88b2b4acb1c58a74022fcec42d02a4c2 (patch) | |
| tree | 67f4caf852924b22fa5238280def2ff854a9a179 | |
| parent | 654b76c34d93f8d31b199afb84edb2742b26444a (diff) | |
| parent | 9b5e0572de9721b90eee769251cf815cf714a5ad (diff) | |
Merge feature/quotetable: watchlist quote table as empty-state landing page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | app.py | 49 | ||||
| -rw-r--r-- | components/quotetable.py | 190 |
2 files changed, 220 insertions, 19 deletions
@@ -404,9 +404,15 @@ hr { ::-webkit-scrollbar-thumb { background: var(--ink-3); border-radius: 3px; } ::-webkit-scrollbar-thumb:hover { background: var(--ink-4); } -/* Hide the watchlist click receiver input */ -[data-testid="stSidebar"] [data-testid="stTextInput"]:has(input[aria-label="wl_click_receiver"]) { - display: none !important; +/* Hide click receiver inputs without removing them from React's event system */ +[data-testid="stSidebar"] [data-testid="stTextInput"]:has(input[aria-label="wl_click_receiver"]), +[data-testid="stTextInput"]:has(input[aria-label="qt_click_receiver"]) { + visibility: hidden !important; + height: 0 !important; + min-height: 0 !important; + overflow: hidden !important; + margin: 0 !important; + padding: 0 !important; } /* ── Ticker Header Band ──────────────────────────────────────────────────── */ @@ -587,6 +593,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.quotetable import render_quotetable from components.overview import render_overview from components.financials import render_financials from components.valuation import render_valuation @@ -765,22 +772,26 @@ st.divider() # ── Main Content ────────────────────────────────────────────────────────────── if not ticker: - st.markdown(""" - <div style="padding:48px 0 32px;text-align:center;"> - <div style=" - font-family:'EB Garamond',Georgia,serif; - font-style:italic;font-size:2.375rem; - color:#F2ECDC;font-weight:400; - letter-spacing:-0.01em;line-height:1.1; - margin-bottom:12px; - ">Search for a ticker to begin.</div> - <div style=" - font-family:'IBM Plex Sans',sans-serif; - font-size:0.875rem;color:#5E5849; - letter-spacing:0.01em; - ">Enter a company name or symbol in the sidebar.</div> - </div> - """, unsafe_allow_html=True) + _watchlist = st.session_state.get("watchlist", []) + if _watchlist: + render_quotetable(_watchlist) + else: + st.markdown(""" + <div style="padding:48px 0 32px;text-align:center;"> + <div style=" + font-family:'EB Garamond',Georgia,serif; + font-style:italic;font-size:2.375rem; + color:#F2ECDC;font-weight:400; + letter-spacing:-0.01em;line-height:1.1; + margin-bottom:12px; + ">Search for a ticker to begin.</div> + <div style=" + font-family:'IBM Plex Sans',sans-serif; + font-size:0.875rem;color:#5E5849; + letter-spacing:0.01em; + ">Enter a company name or symbol in the sidebar.</div> + </div> + """, unsafe_allow_html=True) st.stop() # ── Ticker Header + KPI Strip ───────────────────────────────────────────────── diff --git a/components/quotetable.py b/components/quotetable.py new file mode 100644 index 0000000..28a4d74 --- /dev/null +++ b/components/quotetable.py @@ -0,0 +1,190 @@ +"""QuoteTable — watchlist tickers as a full-width styled data table (empty-state landing page).""" +import streamlit as st +import streamlit.components.v1 as components +from concurrent.futures import ThreadPoolExecutor, as_completed +from services.data_service import get_company_info +from utils.security import escape_html + + +def _fetch_info(sym: str) -> tuple[str, dict]: + return sym, get_company_info(sym) or {} + + +def render_quotetable(watchlist: list[str]) -> None: + """Render watchlist tickers as a styled quote table. + + Shows Symbol · Sector · Last · Δ · % Day · Volume. Clicking a row sets + st.session_state["ticker"] and reruns. Uses the same hidden-input JS + pattern as the sidebar watchlist. + """ + # Process any pending row click — always clear stale state + clicked = st.session_state.get("_qt_click", "") + if clicked: + st.session_state["_qt_click"] = "" + if clicked in watchlist: + st.session_state["ticker"] = clicked + st.rerun() + + st.text_input("qt_click_receiver", key="_qt_click", label_visibility="collapsed") + + # Fetch all tickers in parallel + infos: dict[str, dict] = {} + with ThreadPoolExecutor(max_workers=min(len(watchlist), 8)) as pool: + futures = {pool.submit(_fetch_info, sym): sym for sym in watchlist} + for future in as_completed(futures): + sym, info = future.result() + infos[sym] = info + + # Build row data + rows_html = "" + for sym in watchlist: + try: + info = infos.get(sym, {}) + price = ( + info.get("regularMarketPrice") + or info.get("currentPrice") + or info.get("previousClose") + ) + prev = info.get("regularMarketPreviousClose") or info.get("previousClose") + sector = info.get("sector") or info.get("quoteType") or "" + vol = info.get("volume") + sym_e = escape_html(sym) + sector_e = escape_html(sector) + + if price is not None and prev and prev > 0: + chg = price - prev + pct = chg / prev * 100 + sign = "+" if chg >= 0 else "−" + arrow = "▲" if chg >= 0 else "▼" + chg_cls = "pos" if chg >= 0 else "neg" + px_str = "$" + f"{price:,.2f}" + chg_str = arrow + " " + f"{abs(chg):.2f}" + pct_str = sign + f"{abs(pct):.2f}%" + elif price is not None: + chg_cls = "flat" + px_str = "$" + f"{price:,.2f}" + chg_str = "—" + pct_str = "—" + else: + chg_cls = "flat" + px_str = "—" + chg_str = "—" + pct_str = "—" + + if vol is not None: + if vol >= 1_000_000: + vol_str = f"{vol/1_000_000:.1f}M" + elif vol >= 1_000: + vol_str = f"{vol/1_000:.0f}K" + else: + vol_str = str(vol) + else: + vol_str = "—" + + rows_html += ( + "<tr class='qt-row' data-sym='" + sym_e + "'>" + "<td class='lbl'>" + sym_e + "</td>" + "<td class='sec'>" + sector_e + "</td>" + "<td class='r'>" + px_str + "</td>" + "<td class='r " + chg_cls + "'>" + chg_str + "</td>" + "<td class='r " + chg_cls + "'>" + pct_str + "</td>" + "<td class='r vol'>" + vol_str + "</td>" + "</tr>" + ) + except Exception: + sym_e = escape_html(sym) + rows_html += ( + "<tr class='qt-row' data-sym='" + sym_e + "'>" + "<td class='lbl'>" + sym_e + "</td>" + "<td class='sec'>—</td>" + "<td class='r'>—</td>" + "<td class='r flat'>—</td>" + "<td class='r flat'>—</td>" + "<td class='r vol'>—</td>" + "</tr>" + ) + + n = len(watchlist) + height = 40 + 36 + n * 47 + 24 # card-head + thead + rows + buffer + + html = ( + "<style>" + "* { margin:0; padding:0; box-sizing:border-box; }" + "body { background:transparent; overflow:hidden;" + " font-family:'IBM Plex Sans',sans-serif; color:#F2ECDC; }" + ".psm-card {" + " background:#0D1117; border:1px solid #2E3645; border-radius:6px;" + " overflow:hidden;" + "}" + ".psm-card-head {" + " display:flex; align-items:flex-start; justify-content:space-between;" + " padding:14px 16px 10px; border-bottom:1px solid #2E3645;" + "}" + ".eyebrow {" + " display:block; font-size:10px; font-weight:600;" + " letter-spacing:0.12em; text-transform:uppercase; color:#8E8676;" + " margin-bottom:3px;" + "}" + ".psm-card-head h3 {" + " font-family:'EB Garamond',Georgia,serif; font-size:16px; font-weight:400;" + " color:#F2ECDC; margin:0;" + "}" + ".psm-table { width:100%; border-collapse:collapse; }" + ".psm-table th {" + " font-family:'IBM Plex Sans',sans-serif; font-size:10px; font-weight:600;" + " color:#8E8676; text-transform:uppercase; letter-spacing:0.10em;" + " text-align:left; padding:8px 16px; border-bottom:1px solid #2E3645;" + "}" + ".psm-table th.r { text-align:right; }" + ".psm-table td {" + " font-family:'IBM Plex Mono',monospace; font-variant-numeric:tabular-nums;" + " font-size:13px; color:#C7C0AE;" + " padding:10px 16px; border-bottom:1px solid #232934;" + "}" + ".psm-table td.r { text-align:right; }" + ".psm-table td.lbl { font-family:'IBM Plex Sans',sans-serif; color:#F2ECDC; font-weight:500; }" + ".psm-table td.sec { color:#8E8676; font-family:'IBM Plex Sans',sans-serif; font-size:12px; }" + ".psm-table td.vol { color:#8E8676; }" + ".psm-table td.pos { color:#4F8C5E; }" + ".psm-table td.neg { color:#B5494B; }" + ".psm-table td.flat { color:#8E8676; }" + ".qt-row { cursor:pointer; transition:background 0.1s; }" + ".qt-row:hover { background:rgba(194,170,122,0.05); }" + ".qt-row:last-child td { border-bottom:none; }" + "</style>" + "<div class='psm-card'>" + "<div class='psm-card-head'>" + "<div><span class='eyebrow'>Watchlist</span><h3>Quotes</h3></div>" + "</div>" + "<table class='psm-table'>" + "<thead><tr>" + "<th>Symbol</th><th>Sector</th>" + "<th class='r'>Last</th><th class='r'>Δ</th>" + "<th class='r'>% Day</th><th class='r'>Volume</th>" + "</tr></thead>" + "<tbody>" + rows_html + "</tbody>" + "</table>" + "</div>" + "<script>" + "document.addEventListener('click', function(e) {" + " var row = e.target.closest('tr[data-sym]');" + " if (!row) return;" + " var sym = row.dataset.sym;" + " try {" + " var inputs = window.parent.document.querySelectorAll('input[type=text]');" + " var target = null;" + " for (var i = 0; i < inputs.length; i++) {" + " if (inputs[i].getAttribute('aria-label') === 'qt_click_receiver') {" + " target = inputs[i]; break;" + " }" + " }" + " if (!target) return;" + " var setter = Object.getOwnPropertyDescriptor(window.parent.HTMLInputElement.prototype, 'value').set;" + " setter.call(target, sym);" + " target.dispatchEvent(new window.parent.Event('input', { bubbles: true }));" + " } catch(e) { console.warn('quotetable click failed', e); }" + "});" + "</script>" + ) + + components.html(html, height=height, scrolling=False) |
