1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
|
"""Watchlist sidebar component — session-scoped personal watchlist."""
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_watchlist():
"""Render the personal watchlist section in the sidebar.
Displays a section label, then one clickable row per saved ticker showing
symbol · price · Δ%. Clicking a row sets st.session_state["ticker"] and
triggers a rerun. Shows an empty-state message when the list is empty.
Must be called from within a `with st.sidebar:` block.
"""
watchlist = st.session_state.get("watchlist", [])
# Process any pending click from the JS click handler
clicked = st.session_state.get("_wl_click", "")
if clicked and clicked in watchlist:
st.session_state["ticker"] = clicked
st.session_state["_wl_click"] = ""
st.rerun()
# Hidden click receiver — JS will set this input's value on row click
st.text_input("wl_click_receiver", key="_wl_click", label_visibility="collapsed")
# Build rows HTML (string concatenation, NOT f-strings — JS {} would break)
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")
sym_e = escape_html(sym)
if price is not None and prev and prev > 0:
chg_pct = (price - prev) / prev * 100
sign = "+" if chg_pct >= 0 else "−"
chg_cls = "pos" if chg_pct >= 0 else "neg"
chg_str = sign + f"{abs(chg_pct):.2f}%"
px_str = "$" + f"{price:,.2f}"
elif price is not None:
chg_cls = "flat"
chg_str = "—"
px_str = "$" + f"{price:,.2f}"
else:
chg_cls = "flat"
chg_str = "—"
px_str = "—"
rows_html += (
"<div class='wl-row' data-sym='" + sym_e + "' onclick='selectTicker(this.dataset.sym)'>"
"<span class='wl-sym'>" + sym_e + "</span>"
"<span class='wl-px'>" + px_str + "</span>"
"<span class='wl-chg " + chg_cls + "'>" + chg_str + "</span>"
"</div>"
)
except Exception:
sym_e = escape_html(sym)
rows_html += (
"<div class='wl-row' data-sym='" + sym_e + "' onclick='selectTicker(this.dataset.sym)'>"
"<span class='wl-sym'>" + sym_e + "</span>"
"<span class='wl-px wl-err'>—</span>"
"<span class='wl-chg flat'>—</span>"
"</div>"
)
if not rows_html:
content_html = "<div class='wl-empty'>No saved tickers</div>"
else:
content_html = rows_html
height = 28 + max(len(watchlist), 1) * 34 + 16 # label + rows + buffer
# Build the full HTML block (string concat, not f-string — JS {} would break)
html = (
"<style>"
"* { margin: 0; padding: 0; box-sizing: border-box; }"
"body { background: transparent; overflow: hidden; font-family: 'IBM Plex Mono', monospace; }"
".wl-label { font-family: 'IBM Plex Sans', sans-serif; font-size: 10px; font-weight: 600;"
" text-transform: uppercase; letter-spacing: 0.14em; color: #5E5849; padding: 0 0 6px; display: block; }"
".wl-row { display: grid; grid-template-columns: 1fr auto auto; gap: 8px;"
" align-items: center; padding: 6px 4px; border-bottom: 1px solid #232934;"
" cursor: pointer; transition: background 0.1s; }"
".wl-row:last-child { border-bottom: none; }"
".wl-row:hover { background: #11151C; }"
".wl-sym { font-size: 12px; color: #F2ECDC; font-weight: 500; }"
".wl-px { font-size: 12px; color: #C7C0AE; font-variant-numeric: tabular-nums; text-align: right; }"
".wl-chg { font-size: 11px; min-width: 54px; text-align: right; font-variant-numeric: tabular-nums; }"
".wl-chg.pos { color: #4F8C5E; }"
".wl-chg.neg { color: #B5494B; }"
".wl-chg.flat { color: #8E8676; }"
".wl-empty { font-family: 'IBM Plex Sans', sans-serif; font-size: 11px; color: #5E5849;"
" font-style: italic; padding: 4px 0 8px; }"
"</style>"
"<span class='wl-label'>Watchlist</span>"
+ content_html +
"<script>"
"function selectTicker(sym) {"
" try {"
" var inputs = window.parent.document.querySelectorAll('input[type=text]');"
" var target = null;"
" for (var i = 0; i < inputs.length; i++) {"
" var inp = inputs[i];"
" if (inp.getAttribute('aria-label') === 'wl_click_receiver') { target = inp; 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('watchlist click failed', e); }"
"}"
"</script>"
)
components.html(html, height=height, scrolling=False)
|