From fbf5bc37df61c0349647217cbbf0ad7a9d197fc5 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 17 May 2026 00:24:43 -0700 Subject: Add QuoteTable as empty-state landing page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders watchlist tickers as a styled psm-card data table (Symbol, Sector, Last, Δ, % Day, Volume) when no ticker is selected. Falls back to the existing placeholder when the watchlist is empty. Uses the same JS hidden-input click pattern as the sidebar watchlist. Co-Authored-By: Claude Sonnet 4.6 --- app.py | 37 ++++++----- components/quotetable.py | 169 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+), 16 deletions(-) create mode 100644 components/quotetable.py diff --git a/app.py b/app.py index 8145d03..c9f36a7 100644 --- a/app.py +++ b/app.py @@ -587,6 +587,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 +766,26 @@ st.divider() # ── Main Content ────────────────────────────────────────────────────────────── if not ticker: - st.markdown(""" -
-
Search for a ticker to begin.
-
Enter a company name or symbol in the sidebar.
-
- """, unsafe_allow_html=True) + _watchlist = st.session_state.get("watchlist", []) + if _watchlist: + render_quotetable(_watchlist) + else: + st.markdown(""" +
+
Search for a ticker to begin.
+
Enter a company name or symbol in the sidebar.
+
+ """, 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..e85c335 --- /dev/null +++ b/components/quotetable.py @@ -0,0 +1,169 @@ +"""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 services.data_service import get_latest_price, get_company_info +from utils.security import escape_html + + +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 + clicked = st.session_state.get("_qt_click", "") + if clicked and clicked in watchlist: + st.session_state["ticker"] = clicked + st.session_state["_qt_click"] = "" + st.rerun() + + st.text_input("qt_click_receiver", key="_qt_click", label_visibility="collapsed") + + # Build row data + rows_html = "" + for sym in watchlist: + try: + price = get_latest_price(sym) + info = get_company_info(sym) or {} + 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 += ( + "" + "" + sym_e + "" + "" + sector_e + "" + "" + px_str + "" + "" + chg_str + "" + "" + pct_str + "" + "" + vol_str + "" + "" + ) + except Exception: + sym_e = escape_html(sym) + rows_html += ( + "" + "" + sym_e + "" + "—" + "—" + "—" + "—" + "—" + "" + ) + + n = len(watchlist) + height = 40 + 36 + n * 47 + 24 # card-head + thead + rows + buffer + + html = ( + "" + "
" + "
" + "
Watchlist

Positions

" + "
" + "" + "" + "" + "" + "" + "" + "" + rows_html + "" + "
SymbolSectorLastΔ% DayVolume
" + "
" + "" + ) + + components.html(html, height=height, scrolling=False) -- cgit v1.3-2-g0d8e From c3a535c987b680f1c715c42a41f061a4f8c9df86 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 17 May 2026 00:33:23 -0700 Subject: Fix quotetable row click: use data-sym + event delegation Replaced inline onclick with escaped string literals with the same data-sym / closest(tr[data-sym]) event delegation pattern used by the working sidebar watchlist. Avoids JS string quoting issues in HTML attribute context. Co-Authored-By: Claude Sonnet 4.6 --- components/quotetable.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/components/quotetable.py b/components/quotetable.py index e85c335..e975a67 100644 --- a/components/quotetable.py +++ b/components/quotetable.py @@ -64,7 +64,7 @@ def render_quotetable(watchlist: list[str]) -> None: vol_str = "—" rows_html += ( - "" + "" "" + sym_e + "" "" + sector_e + "" "" + px_str + "" @@ -76,7 +76,7 @@ def render_quotetable(watchlist: list[str]) -> None: except Exception: sym_e = escape_html(sym) rows_html += ( - "" + "" "" + sym_e + "" "—" "—" @@ -148,7 +148,10 @@ def render_quotetable(watchlist: list[str]) -> None: "" "" "" ) -- cgit v1.3-2-g0d8e From 5d24e21aa4ec234d51258b0741543b38c233c542 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 17 May 2026 00:36:07 -0700 Subject: Fix click receivers: visibility:hidden instead of display:none display:none removes elements from React's event processing, causing JS-dispatched input events to be silently dropped. Switching to visibility:hidden + height:0 keeps the inputs in the React tree so programmatic value changes trigger reruns correctly. Also covers the new qt_click_receiver for the quotetable. Co-Authored-By: Claude Sonnet 4.6 --- app.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/app.py b/app.py index c9f36a7..e87482d 100644 --- a/app.py +++ b/app.py @@ -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 ──────────────────────────────────────────────────── */ -- cgit v1.3-2-g0d8e From 9b5e0572de9721b90eee769251cf815cf714a5ad Mon Sep 17 00:00:00 2001 From: Tyler Date: Sun, 17 May 2026 00:40:20 -0700 Subject: Fix quotetable heading, stale click state, and parallel fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename "Positions" → "Quotes" (heading was misleading; implies holdings) - Always clear _qt_click on read so stale values from removed tickers don't persist across rerenders - Replace serial get_latest_price + get_company_info calls with parallel ThreadPoolExecutor fetch of get_company_info only; regularMarketPrice/currentPrice from info is sufficient for the table and halves the number of yfinance calls Co-Authored-By: Claude Sonnet 4.6 --- components/quotetable.py | 34 ++++++++++++++++++++++++++-------- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/components/quotetable.py b/components/quotetable.py index e975a67..28a4d74 100644 --- a/components/quotetable.py +++ b/components/quotetable.py @@ -1,10 +1,15 @@ """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 services.data_service import get_latest_price, get_company_info +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. @@ -12,21 +17,34 @@ def render_quotetable(watchlist: list[str]) -> None: st.session_state["ticker"] and reruns. Uses the same hidden-input JS pattern as the sidebar watchlist. """ - # Process any pending row click + # Process any pending row click — always clear stale state clicked = st.session_state.get("_qt_click", "") - if clicked and clicked in watchlist: - st.session_state["ticker"] = clicked + if clicked: st.session_state["_qt_click"] = "" - st.rerun() + 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: - price = get_latest_price(sym) - info = get_company_info(sym) or {} + 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") @@ -136,7 +154,7 @@ def render_quotetable(watchlist: list[str]) -> None: "" "
" "
" - "
Watchlist

Positions

" + "
Watchlist

Quotes

" "
" "" "" -- cgit v1.3-2-g0d8e