diff options
| -rw-r--r-- | app.py | 101 | ||||
| -rw-r--r-- | components/macro.py | 320 | ||||
| -rw-r--r-- | components/persistence.py | 62 | ||||
| -rw-r--r-- | components/quotetable.py | 190 |
4 files changed, 653 insertions, 20 deletions
@@ -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'>Δ</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) |
