aboutsummaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
Diffstat (limited to 'components')
-rw-r--r--components/quotetable.py190
1 files changed, 190 insertions, 0 deletions
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'>&#916;</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)