aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app.py24
-rw-r--r--components/macro.py22
-rw-r--r--components/persistence.py62
3 files changed, 95 insertions, 13 deletions
diff --git a/app.py b/app.py
index d22635b..87a6716 100644
--- a/app.py
+++ b/app.py
@@ -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;
@@ -406,7 +416,9 @@ hr {
/* 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="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;
@@ -590,6 +602,7 @@ _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
@@ -615,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 ───────────────────────────────────────────────────────────────────
@@ -720,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 = [
@@ -766,7 +786,7 @@ with st.sidebar:
# ── Market Bar ────────────────────────────────────────────────────────────────
-with st.container():
+with st.container(key="market_bar_sticky"):
render_market_bar()
st.divider()
diff --git a/components/macro.py b/components/macro.py
index a166855..c8e8b0c 100644
--- a/components/macro.py
+++ b/components/macro.py
@@ -15,12 +15,11 @@ _INDICES = {
"VIX": "^VIX",
}
-# (symbol, divisor) — yfinance encodes yields as rate×divisor
_YIELDS = {
- "3M": ("^IRX", 100),
- "5Y": ("^FVX", 10),
- "10Y": ("^TNX", 10),
- "30Y": ("^TYX", 10),
+ "3M": "^IRX",
+ "5Y": "^FVX",
+ "10Y": "^TNX",
+ "30Y": "^TYX",
}
_SECTORS = {
@@ -74,15 +73,15 @@ def _get_macro_data() -> dict:
indices[name] = None
yields = {}
- for label, (sym, div) in _YIELDS.items():
+ 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]) / div
- prev = float(closes.iloc[-2]) / div if len(closes) >= 2 else None
+ 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
@@ -176,8 +175,8 @@ def _render_index_table(indices: dict) -> None:
def _render_yield_curve(yields: dict) -> None:
labels = list(yields.keys())
- rates = [yields[l]["rate"] if yields[l] else None for l in labels]
- valid = [(l, r) for l, r in zip(labels, rates) if r is not None]
+ 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:
@@ -283,7 +282,8 @@ def _render_sector_heatmap(sectors: dict) -> None:
# ── Public entry point ────────────────────────────────────────────────────────
def render_macro() -> None:
- data = _get_macro_data()
+ 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;"
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)