diff options
| -rw-r--r-- | app.py | 24 | ||||
| -rw-r--r-- | components/macro.py | 22 | ||||
| -rw-r--r-- | components/persistence.py | 62 |
3 files changed, 95 insertions, 13 deletions
@@ -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) |
