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
117
118
119
120
121
122
123
124
125
126
|
"""Watchlist sidebar component — session-scoped personal watchlist."""
import streamlit as st
from services.data_service import get_latest_price, get_company_info
from utils.security import escape_html
_WATCHLIST_CSS_INJECTED = False
def _inject_css():
global _WATCHLIST_CSS_INJECTED
if _WATCHLIST_CSS_INJECTED:
return
_WATCHLIST_CSS_INJECTED = True
st.markdown("""
<style>
/* ── Watchlist section label ───────────────────────────────────────────── */
.psm-watch-label {
font-family: var(--font-sans);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--fg-4);
margin: 0.5rem 0 0.25rem;
}
/* ── Watchlist row button overrides ────────────────────────────────────── */
[data-testid="stSidebar"] .psm-watch-btn button {
background: transparent !important;
border: none !important;
border-bottom: 1px solid var(--line-1) !important;
border-radius: 0 !important;
padding: 0.3rem 0 !important;
width: 100% !important;
text-align: left !important;
cursor: pointer !important;
display: flex !important;
align-items: center !important;
}
[data-testid="stSidebar"] .psm-watch-btn button:hover {
background: var(--ink-2) !important;
}
[data-testid="stSidebar"] .psm-watch-btn button p {
font-family: var(--font-mono) !important;
font-size: 12px !important;
color: var(--fg-1) !important;
margin: 0 !important;
line-height: 1.3 !important;
}
/* ── Watchlist empty state ──────────────────────────────────────────────── */
.psm-watch-empty {
font-family: var(--font-sans);
font-size: 11px;
color: var(--fg-4);
padding: 0.25rem 0 0.5rem;
font-style: italic;
}
</style>
""", unsafe_allow_html=True)
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.
"""
_inject_css()
watchlist: list[str] = st.session_state.get("watchlist", [])
st.markdown('<div class="psm-watch-label">Watchlist</div>', unsafe_allow_html=True)
if not watchlist:
st.markdown(
'<div class="psm-watch-empty">No saved tickers</div>',
unsafe_allow_html=True,
)
return
for sym in watchlist:
try:
price = get_latest_price(sym)
info = get_company_info(sym) or {}
prev = info.get("regularMarketPreviousClose") or info.get("previousClose")
if price is not None and prev and prev > 0:
chg_pct = (price - prev) / prev * 100
sign = "+" if chg_pct >= 0 else ""
chg_color = "#4F8C5E" if chg_pct >= 0 else "#B5494B"
row_label = (
escape_html(sym)
+ " $" + f"{price:,.2f}"
+ " " + sign + f"{chg_pct:.2f}%"
)
elif price is not None:
chg_color = "#8E8676"
row_label = escape_html(sym) + " $" + f"{price:,.2f}"
else:
chg_color = "#8E8676"
row_label = escape_html(sym) + " —"
# Render a styled container div then the Streamlit button inside it
st.markdown('<div class="psm-watch-btn">', unsafe_allow_html=True)
if st.button(row_label, key="watch_row_" + sym, use_container_width=True):
st.session_state["ticker"] = sym
st.rerun()
st.markdown("</div>", unsafe_allow_html=True)
except Exception:
# Never let one broken ticker blow up the whole watchlist
sym_safe = escape_html(sym)
st.markdown(
'<div class="psm-watch-btn">',
unsafe_allow_html=True,
)
if st.button(sym_safe + " (error)", key="watch_row_" + sym, use_container_width=True):
st.session_state["ticker"] = sym
st.rerun()
st.markdown("</div>", unsafe_allow_html=True)
|