aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app.py101
-rw-r--r--components/macro.py320
-rw-r--r--components/persistence.py62
-rw-r--r--components/quotetable.py190
4 files changed, 653 insertions, 20 deletions
diff --git a/app.py b/app.py
index 8145d03..87a6716 100644
--- a/app.py
+++ b/app.py
@@ -220,6 +220,16 @@ button[kind="secondary"]:hover {
padding-bottom: 3rem !important;
}
+/* ── Sticky market bar ──────────────────────────────────────────────────── */
+.st-key-market_bar_sticky {
+ position: sticky !important;
+ top: 0 !important;
+ z-index: 200 !important;
+ background: var(--ink-0) !important;
+ padding-bottom: 0.5rem !important;
+ margin-bottom: -0.5rem !important;
+}
+
/* ── Tabs ───────────────────────────────────────────────────────────────── */
.stTabs [data-baseweb="tab-list"] {
background: var(--ink-2) !important;
@@ -404,9 +414,17 @@ 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"]),
+[data-testid="stTextInput"]:has(input[aria-label="persist_wl"]),
+[data-testid="stTextInput"]:has(input[aria-label="persist_tk"]) {
+ visibility: hidden !important;
+ height: 0 !important;
+ min-height: 0 !important;
+ overflow: hidden !important;
+ margin: 0 !important;
+ padding: 0 !important;
}
/* ── Ticker Header Band ──────────────────────────────────────────────────── */
@@ -584,9 +602,11 @@ _prism_template = go.layout.Template(layout=_prism_layout)
pio.templates["prism"] = _prism_template
pio.templates.default = "prism"
+from components.persistence import render_persistence_bridge
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
@@ -594,6 +614,7 @@ from components.insiders import render_insiders
from components.filings import render_filings
from components.news import render_news
from components.options import render_options
+from components.macro import render_macro
from services.data_service import get_company_info, search_tickers, get_latest_price
import streamlit.components.v1 as components
@@ -607,6 +628,7 @@ if "watchlist" not in st.session_state:
if "active_tab" not in st.session_state:
st.session_state["active_tab"] = "overview"
+render_persistence_bridge()
# ── Sidebar ───────────────────────────────────────────────────────────────────
@@ -712,6 +734,12 @@ with st.sidebar:
ticker = st.session_state["ticker"]
+ # ── Exit to landing page ──────────────────────────────────────────────
+ if st.session_state.get("ticker"):
+ if st.button("← Watchlist", key="exit_ticker", use_container_width=True):
+ st.session_state["ticker"] = None
+ st.rerun()
+
# ── Workspace nav ─────────────────────────────────────────────────────
st.markdown("<div class='psm-nav-section'>Workspace</div>", unsafe_allow_html=True)
_nav = [
@@ -722,6 +750,7 @@ with st.sidebar:
("insiders", "○ Insiders"),
("filings", "▤ Filings"),
("news", "◉ News"),
+ ("macro", "⬡ Macro"),
]
for _tab_id, _tab_label in _nav:
_is_active = st.session_state["active_tab"] == _tab_id
@@ -757,30 +786,62 @@ with st.sidebar:
# ── Market Bar ────────────────────────────────────────────────────────────────
-with st.container():
+with st.container(key="market_bar_sticky"):
render_market_bar()
st.divider()
+# ── ⌘K / Ctrl+K shortcut — focuses sidebar ticker search ─────────────────────
+components.html(
+ "<script>"
+ "document.addEventListener('keydown', function(e) {"
+ " if (e.key === '/' && !e.metaKey && !e.ctrlKey && !e.altKey"
+ " && document.activeElement.tagName !== 'INPUT'"
+ " && document.activeElement.tagName !== 'TEXTAREA') {"
+ " var inputs = window.parent.document.querySelectorAll('input');"
+ " for (var i = 0; i < inputs.length; i++) {"
+ " if (inputs[i].placeholder && inputs[i].placeholder.indexOf('AAPL') > -1) {"
+ " e.preventDefault();"
+ " inputs[i].focus(); inputs[i].select(); break;"
+ " }"
+ " }"
+ " }"
+ "});"
+ "</script>",
+ height=0,
+ scrolling=False,
+)
+
# ── Main Content ──────────────────────────────────────────────────────────────
+if st.session_state["active_tab"] == "macro":
+ try:
+ render_macro()
+ except Exception as e:
+ st.error(f"Macro data failed to load: {e}")
+ st.stop()
+
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/macro.py b/components/macro.py
new file mode 100644
index 0000000..c8e8b0c
--- /dev/null
+++ b/components/macro.py
@@ -0,0 +1,320 @@
+"""Macro tab — market-wide panels: Index Performance, Yield Curve, Sector Heatmap."""
+import streamlit as st
+import streamlit.components.v1 as _html
+import plotly.graph_objects as go
+import yfinance as yf
+import pandas as pd
+from datetime import date as _date
+
+
+_INDICES = {
+ "S&P 500": "^GSPC",
+ "NASDAQ": "^IXIC",
+ "DOW": "^DJI",
+ "Russell": "^RUT",
+ "VIX": "^VIX",
+}
+
+_YIELDS = {
+ "3M": "^IRX",
+ "5Y": "^FVX",
+ "10Y": "^TNX",
+ "30Y": "^TYX",
+}
+
+_SECTORS = {
+ "Technology": "XLK",
+ "Financials": "XLF",
+ "Healthcare": "XLV",
+ "Energy": "XLE",
+ "Industrials": "XLI",
+ "Cons. Disc.": "XLY",
+ "Cons. Stap.": "XLP",
+ "Utilities": "XLU",
+ "Real Estate": "XLRE",
+ "Materials": "XLB",
+ "Comm. Svcs.": "XLC",
+}
+
+
+# ── Data fetch ────────────────────────────────────────────────────────────────
+
+@st.cache_data(ttl=300)
+def _get_macro_data() -> dict:
+ indices = {}
+ for name, sym in _INDICES.items():
+ try:
+ hist = yf.Ticker(sym).history(period="1y")
+ if hist.empty:
+ indices[name] = None
+ continue
+ closes = hist["Close"].dropna()
+ last = float(closes.iloc[-1])
+
+ def _pct(n):
+ if len(closes) > n:
+ return float((closes.iloc[-1] - closes.iloc[-(n + 1)]) / closes.iloc[-(n + 1)] * 100)
+ return None
+
+ p1d = _pct(1)
+ p1w = _pct(5)
+ p1m = _pct(21)
+
+ today = _date.today()
+ jan1 = pd.Timestamp(today.year, 1, 1)
+ idx = closes.index
+ if idx.tz is not None:
+ jan1 = jan1.tz_localize(idx.tz)
+ ytd_slice = closes[idx.normalize() >= jan1.normalize()]
+ pytd = float((last - float(ytd_slice.iloc[0])) / float(ytd_slice.iloc[0]) * 100) if not ytd_slice.empty else None
+
+ indices[name] = {"price": last, "1d": p1d, "1w": p1w, "1m": p1m, "ytd": pytd}
+ except Exception:
+ indices[name] = None
+
+ yields = {}
+ for label, sym in _YIELDS.items():
+ try:
+ hist = yf.Ticker(sym).history(period="5d")
+ if hist.empty:
+ yields[label] = None
+ continue
+ closes = hist["Close"].dropna()
+ curr = float(closes.iloc[-1])
+ prev = float(closes.iloc[-2]) if len(closes) >= 2 else None
+ yields[label] = {"rate": curr, "change_1d": (curr - prev) if prev is not None else None}
+ except Exception:
+ yields[label] = None
+
+ sectors = {}
+ for name, etf in _SECTORS.items():
+ try:
+ hist = yf.Ticker(etf).history(period="2d")
+ if len(hist) >= 2:
+ pct = float((hist["Close"].iloc[-1] - hist["Close"].iloc[-2]) / hist["Close"].iloc[-2] * 100)
+ price = float(hist["Close"].iloc[-1])
+ elif len(hist) == 1:
+ pct = 0.0
+ price = float(hist["Close"].iloc[-1])
+ else:
+ sectors[name] = None
+ continue
+ sectors[name] = {"etf": etf, "pct": pct, "price": price}
+ except Exception:
+ sectors[name] = None
+
+ return {"indices": indices, "yields": yields, "sectors": sectors}
+
+
+# ── Helpers ───────────────────────────────────────────────────────────────────
+
+def _pct_cell(v) -> str:
+ """Return an HTML <span> with color class for a % value."""
+ if v is None:
+ return "<span class='flat'>—</span>"
+ sign = "+" if v >= 0 else ""
+ cls = "pos" if v > 0.005 else ("neg" if v < -0.005 else "flat")
+ return "<span class='" + cls + "'>" + sign + f"{v:.2f}%" + "</span>"
+
+
+def _sector_bg(pct: float) -> str:
+ """Interpolate background color between neutral and pos/neg based on magnitude."""
+ neutral = (24, 29, 38)
+ pos_bg = (21, 36, 26)
+ neg_bg = (42, 21, 23)
+ intensity = min(1.0, abs(pct) / 3.0)
+ target = pos_bg if pct >= 0 else neg_bg
+ r = int(neutral[0] + (target[0] - neutral[0]) * intensity)
+ g = int(neutral[1] + (target[1] - neutral[1]) * intensity)
+ b = int(neutral[2] + (target[2] - neutral[2]) * intensity)
+ return "rgb(" + str(r) + "," + str(g) + "," + str(b) + ")"
+
+
+# ── Panel renderers ───────────────────────────────────────────────────────────
+
+def _render_index_table(indices: dict) -> None:
+ css = (
+ "<style>"
+ "* { margin:0; padding:0; box-sizing:border-box; }"
+ "body { background:#0B0E13; font-family:'IBM Plex Mono',monospace; overflow:hidden; }"
+ "table { width:100%; border-collapse:collapse; }"
+ "thead th {"
+ " font-family:'IBM Plex Sans',sans-serif; font-size:10px; font-weight:600;"
+ " text-transform:uppercase; letter-spacing:0.14em; color:#5E5849;"
+ " padding:7px 12px 7px; border-bottom:1px solid #232934; text-align:right; }"
+ "thead th:first-child { text-align:left; }"
+ "tbody tr { border-bottom:1px solid #181D26; }"
+ "tbody td { padding:9px 12px; font-size:13px; color:#C7C0AE; text-align:right; "
+ " font-variant-numeric:tabular-nums; }"
+ "tbody td:first-child { text-align:left; color:#F2ECDC; font-size:12px; letter-spacing:0.02em; }"
+ ".pos { color:#4F8C5E; } .neg { color:#B5494B; } .flat { color:#5E5849; }"
+ "</style>"
+ )
+ head = (
+ "<table><thead><tr>"
+ "<th>Index</th><th>Price</th><th>1D</th><th>1W</th><th>1M</th><th>YTD</th>"
+ "</tr></thead><tbody>"
+ )
+ rows = ""
+ for name, d in indices.items():
+ if d is None:
+ rows += "<tr><td>" + name + "</td><td>—</td><td>—</td><td>—</td><td>—</td><td>—</td></tr>"
+ continue
+ price = f"{d['price']:,.2f}"
+ rows += (
+ "<tr><td>" + name + "</td>"
+ "<td>" + price + "</td>"
+ "<td>" + _pct_cell(d["1d"]) + "</td>"
+ "<td>" + _pct_cell(d["1w"]) + "</td>"
+ "<td>" + _pct_cell(d["1m"]) + "</td>"
+ "<td>" + _pct_cell(d["ytd"]) + "</td>"
+ "</tr>"
+ )
+ _html.html(css + head + rows + "</tbody></table>", height=290, scrolling=False)
+
+
+def _render_yield_curve(yields: dict) -> None:
+ labels = list(yields.keys())
+ rates = [yields[lbl]["rate"] if yields[lbl] else None for lbl in labels]
+ valid = [(lbl, r) for lbl, r in zip(labels, rates) if r is not None]
+
+ fig = go.Figure()
+ if valid:
+ xl, yl = zip(*valid)
+ fig.add_trace(go.Scatter(
+ x=list(xl),
+ y=list(yl),
+ mode="lines+markers",
+ line=dict(color="#C2AA7A", width=2),
+ marker=dict(size=7, color="#C2AA7A", line=dict(width=1, color="#8F7A50")),
+ hovertemplate="%{x}: %{y:.2f}%<extra></extra>",
+ ))
+
+ fig.update_layout(
+ xaxis_title="Maturity",
+ yaxis=dict(tickformat=".2f", ticksuffix="%"),
+ height=240,
+ margin=dict(l=52, r=16, t=16, b=40),
+ )
+ st.plotly_chart(fig, use_container_width=True)
+
+
+def _render_yield_table(yields: dict) -> None:
+ css = (
+ "<style>"
+ "* { margin:0; padding:0; box-sizing:border-box; }"
+ "body { background:#0B0E13; font-family:'IBM Plex Mono',monospace; overflow:hidden; }"
+ "table { width:100%; border-collapse:collapse; }"
+ "thead th { font-family:'IBM Plex Sans',sans-serif; font-size:10px; font-weight:600;"
+ " text-transform:uppercase; letter-spacing:0.14em; color:#5E5849;"
+ " padding:6px 10px; border-bottom:1px solid #232934; text-align:right; }"
+ "thead th:first-child { text-align:left; }"
+ "tbody tr { border-bottom:1px solid #181D26; }"
+ "tbody td { padding:8px 10px; font-size:13px; color:#C7C0AE; text-align:right;"
+ " font-variant-numeric:tabular-nums; }"
+ "tbody td:first-child { text-align:left; color:#F2ECDC; }"
+ ".pos { color:#4F8C5E; } .neg { color:#B5494B; } .flat { color:#5E5849; }"
+ "</style>"
+ )
+ head = (
+ "<table><thead><tr>"
+ "<th>Maturity</th><th>Yield</th><th>1D Chg</th>"
+ "</tr></thead><tbody>"
+ )
+ rows = ""
+ for label, d in yields.items():
+ if d is None:
+ rows += "<tr><td>" + label + "</td><td>—</td><td>—</td></tr>"
+ continue
+ rate = f"{d['rate']:.2f}%"
+ chg = d["change_1d"]
+ sign = "+" if chg is not None and chg >= 0 else ""
+ chg_s = (sign + f"{chg:.2f}%") if chg is not None else "—"
+ cls = "pos" if (chg or 0) > 0.001 else ("neg" if (chg or 0) < -0.001 else "flat")
+ rows += (
+ "<tr><td>" + label + "</td>"
+ "<td>" + rate + "</td>"
+ "<td><span class='" + cls + "'>" + chg_s + "</span></td>"
+ "</tr>"
+ )
+ _html.html(css + head + rows + "</tbody></table>", height=240, scrolling=False)
+
+
+def _render_sector_heatmap(sectors: dict) -> None:
+ css = (
+ "<style>"
+ "* { margin:0; padding:0; box-sizing:border-box; }"
+ "body { background:#0B0E13; font-family:'IBM Plex Sans',sans-serif; overflow:hidden; }"
+ ".grid { display:grid; grid-template-columns:repeat(4,1fr); gap:4px; padding:2px; }"
+ ".cell { padding:10px 12px; border-radius:2px; border:1px solid #232934; }"
+ ".cell .name { font-size:11px; font-weight:600; color:#C7C0AE; letter-spacing:0.01em; }"
+ ".cell .etf { font-family:'IBM Plex Mono',monospace; font-size:10px; color:#5E5849; margin-top:1px; }"
+ ".cell .pct { font-family:'IBM Plex Mono',monospace; font-size:14px; font-weight:500; margin-top:5px;"
+ " font-variant-numeric:tabular-nums; }"
+ ".pos { color:#4F8C5E; } .neg { color:#B5494B; } .flat { color:#5E5849; }"
+ "</style>"
+ "<div class='grid'>"
+ )
+ cells = ""
+ for name, d in sectors.items():
+ if d is None:
+ cells += (
+ "<div class='cell' style='background:#11151C'>"
+ "<div class='name'>" + name + "</div>"
+ "<div class='pct flat'>—</div>"
+ "</div>"
+ )
+ continue
+ pct = d["pct"]
+ bg = _sector_bg(pct)
+ sign = "+" if pct >= 0 else ""
+ cls = "pos" if pct > 0.005 else ("neg" if pct < -0.005 else "flat")
+ cells += (
+ "<div class='cell' style='background:" + bg + "'>"
+ "<div class='name'>" + name + "</div>"
+ "<div class='etf'>" + d["etf"] + "</div>"
+ "<div class='pct " + cls + "'>" + sign + f"{pct:.2f}%" + "</div>"
+ "</div>"
+ )
+ _html.html(css + cells + "</div>", height=340, scrolling=False)
+
+
+# ── Public entry point ────────────────────────────────────────────────────────
+
+def render_macro() -> None:
+ with st.spinner("Loading macro data…"):
+ data = _get_macro_data()
+
+ st.markdown(
+ "<h5 style='font-family:\"IBM Plex Sans\",sans-serif;font-size:10px;font-weight:600;"
+ "text-transform:uppercase;letter-spacing:0.14em;color:#5E5849;margin-bottom:8px;'>Index Performance</h5>",
+ unsafe_allow_html=True,
+ )
+ _render_index_table(data["indices"])
+
+ st.markdown("<div style='height:24px'></div>", unsafe_allow_html=True)
+
+ col1, col2 = st.columns([3, 2])
+ with col1:
+ st.markdown(
+ "<h5 style='font-family:\"IBM Plex Sans\",sans-serif;font-size:10px;font-weight:600;"
+ "text-transform:uppercase;letter-spacing:0.14em;color:#5E5849;margin-bottom:8px;'>Yield Curve</h5>",
+ unsafe_allow_html=True,
+ )
+ _render_yield_curve(data["yields"])
+ with col2:
+ st.markdown(
+ "<h5 style='font-family:\"IBM Plex Sans\",sans-serif;font-size:10px;font-weight:600;"
+ "text-transform:uppercase;letter-spacing:0.14em;color:#5E5849;margin-bottom:8px;'>Treasury Yields</h5>",
+ unsafe_allow_html=True,
+ )
+ _render_yield_table(data["yields"])
+
+ st.markdown("<div style='height:24px'></div>", unsafe_allow_html=True)
+
+ st.markdown(
+ "<h5 style='font-family:\"IBM Plex Sans\",sans-serif;font-size:10px;font-weight:600;"
+ "text-transform:uppercase;letter-spacing:0.14em;color:#5E5849;margin-bottom:8px;'>Sector Performance — Today</h5>",
+ unsafe_allow_html=True,
+ )
+ _render_sector_heatmap(data["sectors"])
diff --git a/components/persistence.py b/components/persistence.py
new file mode 100644
index 0000000..552347d
--- /dev/null
+++ b/components/persistence.py
@@ -0,0 +1,62 @@
+import json
+import streamlit as st
+import streamlit.components.v1 as components
+from utils.security import escape_html
+
+
+def render_persistence_bridge() -> None:
+ """Sync watchlist + ticker to/from localStorage."""
+ restored_wl = st.session_state.get("_persist_wl", "")
+ restored_tk = st.session_state.get("_persist_tk", "")
+ if restored_wl and not st.session_state.get("_persist_loaded"):
+ try:
+ st.session_state["watchlist"] = json.loads(restored_wl)
+ except Exception:
+ pass
+ if restored_tk:
+ st.session_state["ticker"] = restored_tk
+ st.session_state["_persist_loaded"] = True
+ # No st.rerun() — the bridge runs at the top of the render cycle so the
+ # restored values propagate naturally to components below. Calling
+ # st.rerun() here would abort the render before click-receiver inputs
+ # (qt_click_receiver, wl_click_receiver) are registered, causing
+ # Streamlit to clear their pending values and swallow the click.
+
+ st.text_input("persist_wl", key="_persist_wl", label_visibility="collapsed")
+ st.text_input("persist_tk", key="_persist_tk", label_visibility="collapsed")
+
+ loaded = "1" if st.session_state.get("_persist_loaded") else "0"
+ wl_json = escape_html(json.dumps(st.session_state.get("watchlist", [])))
+ tk_val = escape_html(st.session_state.get("ticker") or "")
+
+ html = (
+ "<div id='d' data-loaded='" + loaded + "' data-wl='" + wl_json + "' data-tk='" + tk_val + "'></div>"
+ "<script>"
+ "(function() {"
+ " var d = document.getElementById('d');"
+ " function setInput(label, val) {"
+ " var inputs = window.parent.document.querySelectorAll('input[type=text]');"
+ " for (var i = 0; i < inputs.length; i++) {"
+ " if (inputs[i].getAttribute('aria-label') === label) {"
+ " var setter = Object.getOwnPropertyDescriptor(window.parent.HTMLInputElement.prototype, 'value').set;"
+ " setter.call(inputs[i], val);"
+ " inputs[i].dispatchEvent(new window.parent.Event('input', { bubbles: true }));"
+ " return;"
+ " }"
+ " }"
+ " }"
+ " if (d.dataset.loaded === '1') {"
+ " localStorage.setItem('prism_watchlist', d.dataset.wl);"
+ " if (d.dataset.tk) localStorage.setItem('prism_ticker', d.dataset.tk);"
+ " else localStorage.removeItem('prism_ticker');"
+ " } else {"
+ " var stored_wl = localStorage.getItem('prism_watchlist');"
+ " var stored_tk = localStorage.getItem('prism_ticker') || '';"
+ " if (!stored_wl) return;"
+ " setInput('persist_wl', stored_wl);"
+ " if (stored_tk) setInput('persist_tk', stored_tk);"
+ " }"
+ "})();"
+ "</script>"
+ )
+ components.html(html, height=0, scrolling=False)
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)