aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app.py37
-rw-r--r--components/quotetable.py169
2 files changed, 190 insertions, 16 deletions
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("""
- <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..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 += (
+ "<tr class='qt-row' onclick='selectQtTicker(\"" + 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' onclick='selectQtTicker(\"" + 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>Positions</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>"
+ "function selectQtTicker(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)