diff options
| author | Tyler <tyler@tylerhoang.xyz> | 2026-05-16 00:43:47 -0700 |
|---|---|---|
| committer | Tyler <tyler@tylerhoang.xyz> | 2026-05-16 00:43:47 -0700 |
| commit | 49368331324f201307fd260d288e5c5f65ea3fcc (patch) | |
| tree | ec1a91e105e506d85f0b7e371220320777e16eeb /components/overview.py | |
| parent | 05b7005a414465f7abc55d149b2272854325875d (diff) | |
Rewrite overview tab as client-side HTML surface
Diffstat (limited to 'components/overview.py')
| -rw-r--r-- | components/overview.py | 770 |
1 files changed, 427 insertions, 343 deletions
diff --git a/components/overview.py b/components/overview.py index 433f6e5..27c4166 100644 --- a/components/overview.py +++ b/components/overview.py @@ -1,11 +1,14 @@ -"""Company overview — score card, key stats, 52W range, short interest, price chart.""" +"""Company overview tab rendered as a client-side HTML surface.""" +from datetime import datetime +from html import escape as _esc + +import pandas as pd import streamlit as st -import plotly.graph_objects as go -from services.data_service import get_company_info, get_price_history -from utils.formatters import fmt_large, fmt_currency, fmt_pct, fmt_ratio +import streamlit.components.v1 as components +from services.data_service import get_company_info, get_latest_price, get_price_history -PERIODS = {"1 Month": "1mo", "3 Months": "3mo", "6 Months": "6mo", "1 Year": "1y", "5 Years": "5y"} +PERIODS = {"1mo": "1M", "3mo": "3M", "6mo": "6M", "1y": "1Y", "5y": "5Y"} SECTOR_ETF_MAP = { "Technology": "XLK", "Communication Services": "XLC", @@ -41,405 +44,486 @@ SECTOR_PEER_MAP = { "Utilities": ["NEE", "DUK", "SO"], "Real Estate": ["PLD", "AMT", "EQIX"], } +_XMAP = {"NYQ": "NYSE", "NMS": "NASDAQ", "NGM": "NASDAQ", "NCM": "NASDAQ", "ASE": "AMEX"} -# ── Score card ─────────────────────────────────────────────────────────────── +def _to_float(val): + try: + n = float(val) + except (TypeError, ValueError): + return None + if pd.isna(n): + return None + return n -def _score_card(info: dict) -> None: - """Render a row of green/yellow/red signal badges.""" - signals: list[tuple[str, str, str, str]] = [] # (label, color, value, description) - # Valuation — trailing P/E - pe = info.get("trailingPE") - if pe and pe > 0: +def _to_int(val): + try: + n = float(val) + except (TypeError, ValueError): + return None + if pd.isna(n): + return None + return int(round(n)) + + +def _series_points(symbol: str, period: str) -> list[dict]: + hist = get_price_history(symbol, period=period) + if hist is None or hist.empty or "Close" not in hist.columns: + return [] + closes = pd.to_numeric(hist["Close"], errors="coerce") + out = [] + for idx, v in closes.dropna().items(): + ts = idx.strftime("%Y-%m-%d") + out.append({"d": ts, "c": float(v)}) + return out + + +def _build_signals(info: dict) -> list[dict]: + signals = [] + + pe = _to_float(info.get("trailingPE")) + if pe is not None and pe > 0: if pe < 15: - signals.append(("Valuation", "green", f"P/E {pe:.1f}x", "Attractively valued")) + signals.append({"k": "Valuation", "state": "pos", "v": "P/E " + "{:.1f}".format(pe) + "x", "d": "Attractive multiple"}) elif pe < 30: - signals.append(("Valuation", "yellow", f"P/E {pe:.1f}x", "Fairly valued")) + signals.append({"k": "Valuation", "state": "warn", "v": "P/E " + "{:.1f}".format(pe) + "x", "d": "Middle of range"}) else: - signals.append(("Valuation", "red", f"P/E {pe:.1f}x", "Richly valued")) + signals.append({"k": "Valuation", "state": "neg", "v": "P/E " + "{:.1f}".format(pe) + "x", "d": "Premium multiple"}) else: - signals.append(("Valuation", "neutral", "P/E N/A", "No trailing earnings")) + signals.append({"k": "Valuation", "state": "neu", "v": "P/E unavailable", "d": "No trailing earnings"}) - # Revenue growth (TTM YoY) - rev_growth = info.get("revenueGrowth") + rev_growth = _to_float(info.get("revenueGrowth")) if rev_growth is not None: if rev_growth > 0.10: - signals.append(("Growth", "green", f"{rev_growth*100:+.0f}% rev", "Strong growth")) + state = "pos" + desc = "Strong top-line growth" elif rev_growth >= 0: - signals.append(("Growth", "yellow", f"{rev_growth*100:+.0f}% rev", "Slow growth")) + state = "warn" + desc = "Low but positive growth" else: - signals.append(("Growth", "red", f"{rev_growth*100:+.0f}% rev", "Declining revenue")) + state = "neg" + desc = "Contracting revenue" + signals.append({"k": "Growth", "state": state, "v": "{:+.0f}%".format(rev_growth * 100.0), "d": desc}) - # Profitability — net margin - margin = info.get("profitMargins") + margin = _to_float(info.get("profitMargins")) if margin is not None: if margin > 0.15: - signals.append(("Profit", "green", f"{margin*100:.0f}% margin", "High margins")) + state = "pos" + desc = "High net margin" elif margin > 0.05: - signals.append(("Profit", "yellow", f"{margin*100:.0f}% margin", "Moderate margins")) + state = "warn" + desc = "Moderate net margin" else: - signals.append(("Profit", "red", f"{margin*100:.0f}% margin", "Thin/negative margins")) + state = "neg" + desc = "Thin or negative margin" + signals.append({"k": "Profit", "state": state, "v": "{:.0f}%".format(margin * 100.0), "d": desc}) - # Leverage — D/E (yfinance returns as %, e.g. 162 = 1.62x) - de = info.get("debtToEquity") + de = _to_float(info.get("debtToEquity")) if de is not None: - de_x = de / 100 + de_x = de / 100.0 if de_x < 0.5: - signals.append(("Leverage", "green", f"D/E {de_x:.2f}x", "Low leverage")) + state = "pos" + desc = "Low leverage" elif de_x < 2.0: - signals.append(("Leverage", "yellow", f"D/E {de_x:.2f}x", "Moderate leverage")) + state = "warn" + desc = "Moderate leverage" else: - signals.append(("Leverage", "red", f"D/E {de_x:.2f}x", "High leverage")) + state = "neg" + desc = "High leverage" + signals.append({"k": "Leverage", "state": state, "v": "D/E " + "{:.2f}".format(de_x) + "x", "d": desc}) - # Momentum — price vs 52W high - price = info.get("currentPrice") or info.get("regularMarketPrice") - high52 = info.get("fiftyTwoWeekHigh") - if price and high52 and high52 > 0: - from_high_pct = (price - high52) / high52 * 100 - if from_high_pct > -10: - signals.append(("Momentum", "green", f"{from_high_pct:.0f}% from 52W↑", "Near highs")) - elif from_high_pct > -25: - signals.append(("Momentum", "yellow", f"{from_high_pct:.0f}% from 52W↑", "Mid-range")) + price = _to_float(info.get("currentPrice") or info.get("regularMarketPrice")) + high52 = _to_float(info.get("fiftyTwoWeekHigh")) + if price is not None and high52 is not None and high52 > 0: + from_high = (price - high52) / high52 * 100.0 + if from_high > -10: + state = "pos" + desc = "Near 52-week highs" + elif from_high > -25: + state = "warn" + desc = "Mid-range momentum" else: - signals.append(("Momentum", "red", f"{from_high_pct:.0f}% from 52W↑", "Far from highs")) + state = "neg" + desc = "Far below highs" + signals.append({"k": "Momentum", "state": state, "v": "{:+.0f}%".format(from_high), "d": desc}) - # Short interest - short_pct = info.get("shortPercentOfFloat") + short_pct = _to_float(info.get("shortPercentOfFloat")) if short_pct is not None: if short_pct < 0.05: - signals.append(("Short Int.", "green", f"{short_pct*100:.1f}% float", "Low short interest")) + state = "pos" + desc = "Low crowding" elif short_pct < 0.15: - signals.append(("Short Int.", "yellow", f"{short_pct*100:.1f}% float", "Moderate short interest")) + state = "warn" + desc = "Moderate crowding" else: - signals.append(("Short Int.", "red", f"{short_pct*100:.1f}% float", "High short interest")) - - if not signals: - return - - color_map = { - "green": ("#15241A", "#4F8C5E"), - "yellow": ("#2A1F0F", "#C49545"), - "red": ("#2A1517", "#B5494B"), - "neutral": ("#181D26", "#5E5849"), - } - - cards_html = "" - for label, color, value, desc in signals: - bg, fg = color_map[color] - cards_html += ( - f'<div style="background:{bg};border:1px solid {fg}55;border-radius:2px;' - f'padding:8px 12px;flex:1;min-width:110px;">' - f'<div style="font-family:IBM Plex Sans,sans-serif;font-size:10px;font-weight:600;color:#5E5849;text-transform:uppercase;' - f'letter-spacing:0.12em;margin-bottom:3px;">{label}</div>' - f'<div style="font-family:IBM Plex Mono,monospace;font-size:0.875rem;font-weight:500;color:{fg};font-variant-numeric:tabular-nums;">{value}</div>' - f'<div style="font-family:IBM Plex Sans,sans-serif;font-size:0.75rem;color:#8E8676;margin-top:2px;">{desc}</div>' - f'</div>' - ) - - st.markdown( - f'<div style="display:flex;gap:0.5rem;margin-bottom:0.75rem;flex-wrap:wrap;">{cards_html}</div>', - unsafe_allow_html=True, - ) - - -# ── 52-week range bar ──────────────────────────────────────────────────────── - -def _render_52w_bar(info: dict) -> None: - low = info.get("fiftyTwoWeekLow") - high = info.get("fiftyTwoWeekHigh") - price = info.get("currentPrice") or info.get("regularMarketPrice") - - if not (low and high and price and high > low): - return - - pct = max(0.0, min(100.0, (price - low) / (high - low) * 100)) - from_low_pct = (price - low) / low * 100 - to_high_pct = (high - price) / price * 100 - - st.markdown( - f""" - <div style="margin:8px 0 16px 0;"> - <div style="display:flex;justify-content:space-between; - font-family:'IBM Plex Mono',monospace;font-size:11px; - font-variant-numeric:tabular-nums; - color:#8E8676;margin-bottom:6px;"> - <span>{fmt_currency(low)}</span> - <span style="color:#C2AA7A;font-weight:500;"> - {fmt_currency(price)} · {pct:.0f}% - </span> - <span>{fmt_currency(high)}</span> - </div> - <div style="position:relative;height:3px;background:#222934;border-radius:999px;overflow:visible;"> - <div style="position:absolute;left:0;width:{pct}%;height:100%; - background:#C2AA7A;border-radius:999px;"></div> - <div style="position:absolute;left:calc({pct}% - 1px);top:-4px;width:2px;height:11px; - background:#DCC79E;border-radius:1px;"></div> - </div> - <div style="display:flex;justify-content:space-between; - font-family:'IBM Plex Mono',monospace;font-size:10px; - color:#5E5849;margin-top:5px;"> - <span>+{from_low_pct:.1f}% from low</span> - <span>{to_high_pct:.1f}% to high</span> - </div> - </div> - """, - unsafe_allow_html=True, - ) - - -# ── Short interest strip ───────────────────────────────────────────────────── - -def _render_short_interest(info: dict) -> None: - short_pct = info.get("shortPercentOfFloat") - short_ratio = info.get("shortRatio") - shares_short = info.get("sharesShort") - shares_short_prior = info.get("sharesShortPriorMonth") - - if not any([short_pct, short_ratio, shares_short]): - return - - st.markdown("**Short Interest**") - cols = st.columns(4) - - cols[0].metric( - "Short % of Float", - f"{short_pct * 100:.2f}%" if short_pct is not None else "—", - ) - cols[1].metric( - "Days to Cover", - f"{short_ratio:.1f}" if short_ratio is not None else "—", - help="Shares short ÷ avg daily volume. Higher = harder to unwind.", - ) - cols[2].metric( - "Shares Short", - fmt_large(shares_short) if shares_short else "—", - ) - if shares_short and shares_short_prior: - chg = (shares_short - shares_short_prior) / shares_short_prior * 100 - cols[3].metric( - "vs Prior Month", - fmt_large(shares_short_prior), - delta=f"{chg:+.1f}%", - ) - else: - cols[3].metric("Prior Month", fmt_large(shares_short_prior) if shares_short_prior else "—") + state = "neg" + desc = "Elevated crowding" + signals.append({"k": "Short Int.", "state": state, "v": "{:.1f}%".format(short_pct * 100.0), "d": desc}) + return signals -def _suggest_relative_comparisons(ticker: str, info: dict) -> list[tuple[str, str]]: - comparisons: list[tuple[str, str]] = [("S&P 500", "^GSPC")] +def _comparison_list(ticker: str, info: dict) -> list[dict]: + out = [{"label": "S&P 500", "symbol": "^GSPC"}] sector = str(info.get("sector") or "").strip() industry = str(info.get("industry") or "").strip().lower() - sector_etf = SECTOR_ETF_MAP.get(sector) - if sector_etf: - comparisons.append((f"{sector} ETF", sector_etf)) + etf = SECTOR_ETF_MAP.get(sector) + if etf: + out.append({"label": sector + " ETF", "symbol": etf}) - peer_candidates = INDUSTRY_PEER_MAP.get(industry) or SECTOR_PEER_MAP.get(sector) or [] - for peer in peer_candidates: - peer_up = peer.upper() - if peer_up != ticker.upper(): - comparisons.append((peer_up, peer_up)) + peers = INDUSTRY_PEER_MAP.get(industry) or SECTOR_PEER_MAP.get(sector) or [] + for p in peers: + pp = str(p).upper() + if pp != ticker.upper(): + out.append({"label": pp, "symbol": pp}) - deduped: list[tuple[str, str]] = [] + deduped = [] seen = set() - for label, symbol in comparisons: - if symbol not in seen: - deduped.append((label, symbol)) - seen.add(symbol) - return deduped[:5] + for row in out: + sym = row["symbol"] + if sym not in seen: + deduped.append(row) + seen.add(sym) + return deduped[:6] -def _build_relative_series(symbol: str, period: str): - hist = get_price_history(symbol, period=period) - if hist.empty or "Close" not in hist.columns: - return None - - closes = hist["Close"].dropna() - if closes.empty: - return None - - base = float(closes.iloc[0]) - if base <= 0: - return None - - return (closes / base - 1.0) * 100.0 - - -def _render_relative_chart(ticker: str, info: dict, period: str): - options = _suggest_relative_comparisons(ticker, info) - option_map = {label: symbol for label, symbol in options} - default_labels = ["S&P 500"] if "S&P 500" in option_map else [label for label, _ in options[:1]] - - selected_labels = st.multiselect( - "Compare against", - options=list(option_map.keys()), - default=default_labels, - key=f"overview_relative_comparisons_{ticker.upper()}", - help="Performance is rebased to 0% at the start of the selected period.", - ) +def render_overview(ticker: str): + import json as _json - fig = go.Figure() - subject_series = _build_relative_series(ticker, period) - if subject_series is None: - st.warning("No price history available.") + info = get_company_info(ticker) or {} + if not info: + st.error("Could not load data for this ticker.") return - fig.add_trace(go.Scatter( - x=subject_series.index, - y=subject_series.values, - mode="lines", - name=ticker.upper(), - line=dict(color="#C2AA7A", width=2.5), - )) + tkr = str(ticker or "").upper() + name = str(info.get("longName") or info.get("shortName") or tkr) + sector = str(info.get("sector") or "") or None + industry = str(info.get("industry") or "") or None + exchange_raw = str(info.get("exchange") or "") + exchange = _XMAP.get(exchange_raw, exchange_raw) or None - palette = ["#7ce3a1", "#C49545", "#c084fc", "#ff8a8a", "#9ad1ff"] - plotted = 1 - for idx, label in enumerate(selected_labels): - symbol = option_map[label] - series = _build_relative_series(symbol, period) - if series is None: - continue - fig.add_trace(go.Scatter( - x=series.index, - y=series.values, - mode="lines", - name=label, - line=dict(color=palette[idx % len(palette)], width=1.8), - )) - plotted += 1 + price = _to_float(info.get("currentPrice") or info.get("regularMarketPrice") or get_latest_price(ticker)) + prev_close = _to_float(info.get("regularMarketPreviousClose") or info.get("previousClose")) - if plotted == 1: - st.caption("No comparison series were available for the selected period.") + comparisons = _comparison_list(tkr, info) + symbols = [tkr] + for c in comparisons: + sym = c.get("symbol") + if sym and sym not in symbols: + symbols.append(sym) - fig.update_layout( - margin=dict(l=0, r=0, t=10, b=0), - xaxis=dict(showgrid=False, zeroline=False), - yaxis=dict( - showgrid=True, - gridcolor="rgba(255,255,255,0.05)", - zeroline=True, - zerolinecolor="rgba(255,255,255,0.12)", - ticksuffix="%", - ), - plot_bgcolor="rgba(0,0,0,0)", - paper_bgcolor="rgba(0,0,0,0)", - hovermode="x unified", - height=320, - legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="left", x=0), - ) - st.plotly_chart(fig, width="stretch") + series = {} + for period in PERIODS.keys(): + bucket = {} + for sym in symbols: + pts = _series_points(sym, period) + if pts: + bucket[sym] = pts + series[period] = bucket + short_pct = _to_float(info.get("shortPercentOfFloat")) + short_ratio = _to_float(info.get("shortRatio")) + shares_short = _to_int(info.get("sharesShort")) + shares_short_prior = _to_int(info.get("sharesShortPriorMonth")) + short_delta_pct = None + if shares_short is not None and shares_short_prior is not None and shares_short_prior > 0: + short_delta_pct = (float(shares_short) - float(shares_short_prior)) / float(shares_short_prior) -# ── Main render ────────────────────────────────────────────────────────────── + payload = { + "ticker": tkr, + "name": name, + "sector": sector, + "industry": industry, + "exchange": exchange, + "price": price, + "prev_close": prev_close, + "signals": _build_signals(info), + "stats": { + "marketCap": _to_float(info.get("marketCap")), + "trailingPE": _to_float(info.get("trailingPE")), + "trailingEps": _to_float(info.get("trailingEps")), + "volume": _to_float(info.get("volume")), + "averageVolume": _to_float(info.get("averageVolume")), + "beta": _to_float(info.get("beta")), + }, + "range_52w": { + "low": _to_float(info.get("fiftyTwoWeekLow")), + "high": _to_float(info.get("fiftyTwoWeekHigh")), + "price": price, + }, + "short_interest": { + "shortPercentOfFloat": short_pct, + "shortRatio": short_ratio, + "sharesShort": shares_short, + "sharesShortPriorMonth": shares_short_prior, + "sharesShortDeltaPct": short_delta_pct, + }, + "comparisons": comparisons, + "series": series, + } -def render_overview(ticker: str): - info = get_company_info(ticker) - if not info: - st.error(f"Could not load data for **{ticker}**. Check the ticker symbol.") - return + meta = { + "updated_label": datetime.now().strftime("%Y-%m-%d %H:%M"), + "default_period": "1y", + "default_mode": "price", + "default_comparisons": ["^GSPC"], + } - name = info.get("longName") or info.get("shortName", ticker.upper()) - price = info.get("currentPrice") or info.get("regularMarketPrice") - prev_close = info.get("regularMarketPreviousClose") or info.get("previousClose") + payload_js = "const OVERVIEW_DATA=" + _json.dumps(payload) + ";" + meta_js = "const OVERVIEW_META=" + _json.dumps(meta) + ";" - price_change = price_change_pct = None - if price and prev_close: - price_change = price - prev_close - price_change_pct = price_change / prev_close + if price is not None and prev_close is not None and prev_close > 0: + chg_pct = (price - prev_close) / prev_close * 100.0 + chg_sign = "+" if chg_pct >= 0 else "" + chg_arrow = "▲" if chg_pct >= 0 else "▼" + chg_str = chg_arrow + " " + chg_sign + "{:.2f}%".format(chg_pct) + chg_cls = "chg-pos" if chg_pct >= 0 else "chg-neg" + else: + chg_str = "—" + chg_cls = "" - # ── Header ────────────────────────────────────────────────────────────── - col1, col2 = st.columns([3, 1]) - with col1: - st.subheader(f"{name} ({ticker.upper()})") - sector = info.get("sector", "") - industry = info.get("industry", "") - if sector: - st.caption(f"{sector} · {industry}") - with col2: - delta_str = None - if price_change is not None and price_change_pct is not None: - delta_str = f"{price_change:+.2f} ({price_change_pct * 100:+.2f}%)" - st.metric( - label="Price", - value=fmt_currency(price) if price else "—", - delta=delta_str, - ) + price_str = "${:,.2f}".format(price) if price is not None else "—" + co_name = _esc(name) - # ── Score card ────────────────────────────────────────────────────────── - _score_card(info) + plotly_cdn = "<script src='https://cdn.plot.ly/plotly-2.27.0.min.js'></script>" + _ROOT = ( + "<style>*,*::before,*::after{box-sizing:border-box}" + ":root{" + "--ink-0:#0B0E13;--ink-1:#11151C;--ink-2:#181D26;--ink-3:#222934;--ink-4:#2C3340;" + "--line-1:#232934;--line-2:#2E3645;--line-3:#3D4658;" + "--fg-1:#F2ECDC;--fg-2:#C7C0AE;--fg-3:#8E8676;--fg-4:#5E5849;" + "--brass:#C2AA7A;--brass-bright:#DCC79E;--brass-deep:#8F7A50;--brass-ink:#17120A;" + "--oxford:#1F3D5C;--oxford-light:#2E5A87;" + "--positive:#4F8C5E;--positive-bg:#15241A;--negative:#B5494B;--negative-bg:#2A1517;" + "--warning:#C49545;--warning-bg:#2A1F0F;--info:#4A78B5;--info-bg:#11202E;" + "--font-display:'EB Garamond',Georgia,serif;" + "--font-sans:'IBM Plex Sans','Helvetica Neue',system-ui,sans-serif;" + "--font-mono:'IBM Plex Mono','SF Mono',Menlo,monospace;" + "--fs-12:0.75rem;--fs-13:0.8125rem;--fs-14:0.875rem;--fs-16:1rem;--fs-18:1.125rem;" + "--fs-20:1.25rem;--fs-24:1.5rem;--fs-30:1.875rem;--fs-38:2.375rem;" + "--tr-wider:0.12em;--tr-wide:0.04em;--tr-tight:-0.02em;" + "--sp-1:4px;--sp-2:8px;--sp-3:12px;--sp-4:16px;--sp-5:24px;--sp-6:32px;--sp-7:48px;" + "--r-1:2px;--r-2:4px;--r-3:6px;--r-full:999px;" + "--shadow-1:0 1px 3px rgba(0,0,0,0.4);" + "}" + "html,body{margin:0;padding:0;background:var(--ink-0);color:var(--fg-2);" + "font-family:var(--font-sans);font-size:14px;-webkit-font-smoothing:antialiased}" + "</style>" + ) - # ── Key stats strip ───────────────────────────────────────────────────── - stats_cols = st.columns(6) - stats = [ - ("Mkt Cap", fmt_large(info.get("marketCap"))), - ("P/E (TTM)", fmt_ratio(info.get("trailingPE"))), - ("EPS (TTM)", fmt_currency(info.get("trailingEps"))), - ("Volume", fmt_large(info.get("volume"))), - ("Avg Volume", fmt_large(info.get("averageVolume"))), - ("Beta", fmt_ratio(info.get("beta"))), - ] - for col, (label, val) in zip(stats_cols, stats): - col.metric(label, val) + fonts_link = ( + "<link rel='preconnect' href='https://fonts.googleapis.com'>" + "<link href='https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,500;1,400;1,500" + "&family=IBM+Plex+Mono:wght@300;400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap'" + " rel='stylesheet'>" + ) - st.write("") + _OV_CSS = """<style> +.ov-wrap{background:var(--ink-0);min-height:100vh} +.num{font-family:var(--font-mono);font-variant-numeric:tabular-nums} +.eyebrow-lbl{font-family:var(--font-sans);font-size:var(--fs-12);text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-3);font-weight:600} +.val-ctx{display:flex;align-items:center;gap:var(--sp-4);padding:var(--sp-3) var(--sp-5);border-bottom:1px solid var(--line-1);background:var(--ink-1)} +.val-ctx .sym{font-family:var(--font-display);font-size:var(--fs-24);font-weight:500;letter-spacing:var(--tr-tight)} +.val-ctx .name{font-family:var(--font-display);font-style:italic;font-size:var(--fs-16);color:var(--fg-2);margin-left:calc(-1 * var(--sp-1));white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:48ch} +.val-ctx .eyebrow-ctx{font-family:var(--font-sans);font-size:var(--fs-12);text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-3);font-weight:600;white-space:nowrap} +.val-ctx .meta{display:flex;gap:var(--sp-4);margin-left:auto;font-family:var(--font-mono);font-size:var(--fs-12);color:var(--fg-3)} +.val-ctx .meta span{white-space:nowrap} +.val-ctx .meta .px{color:var(--fg-1);font-size:var(--fs-14)} +.val-ctx .meta .chg-pos{color:var(--positive)} +.val-ctx .meta .chg-neg{color:var(--negative)} +.ov-body{padding:var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-4)} +.ov-lede{display:grid;grid-template-columns:1.6fr 1fr;gap:var(--sp-5);align-items:stretch;background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3);padding:var(--sp-5)} +.ov-lede .left{display:flex;flex-direction:column;gap:var(--sp-2)} +.ov-lede .ttl{font-family:var(--font-display);font-size:var(--fs-30);font-weight:500;letter-spacing:var(--tr-tight);line-height:1.1;color:var(--fg-1);margin:var(--sp-1) 0 0} +.ov-lede .sub{font-family:var(--font-sans);font-size:var(--fs-13);color:var(--fg-2);line-height:1.55;max-width:68ch;margin:0} +.ov-lede .right{display:grid;grid-template-columns:repeat(3,1fr);gap:var(--sp-3);align-content:end} +.kr-source{display:flex;flex-direction:column;gap:2px;padding:var(--sp-3) var(--sp-4);background:var(--ink-2);border:1px solid var(--line-1);border-radius:var(--r-2)} +.kr-source .lbl{font-family:var(--font-sans);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-3);font-weight:600} +.kr-source .v{font-family:var(--font-mono);font-size:var(--fs-14);font-variant-numeric:tabular-nums;color:var(--fg-1);font-weight:500} +.ov-signals{display:grid;grid-template-columns:repeat(6,1fr);gap:var(--sp-3)} +.ov-sig{background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3);padding:var(--sp-3) var(--sp-4);display:flex;flex-direction:column;gap:var(--sp-1)} +.ov-sig .k{font-family:var(--font-sans);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-4);font-weight:600} +.ov-sig .v{font-family:var(--font-mono);font-size:var(--fs-14);font-variant-numeric:tabular-nums;color:var(--fg-1)} +.ov-sig .d{font-family:var(--font-sans);font-size:var(--fs-12);color:var(--fg-3)} +.ov-sig.pos{border-color:var(--positive)} +.ov-sig.pos .v{color:var(--positive)} +.ov-sig.warn{border-color:var(--warning)} +.ov-sig.warn .v{color:var(--warning)} +.ov-sig.neg{border-color:var(--negative)} +.ov-sig.neg .v{color:var(--negative)} +.ov-sig.neu .v{color:var(--fg-3)} +.ov-kpis{display:grid;grid-template-columns:repeat(6,1fr);gap:var(--sp-3)} +.ov-kpi{background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3);padding:var(--sp-3) var(--sp-4);display:flex;flex-direction:column;gap:var(--sp-1)} +.ov-kpi .lbl{font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-4)} +.ov-kpi .v{font-family:var(--font-mono);font-size:var(--fs-24);font-variant-numeric:tabular-nums;color:var(--fg-1);line-height:1.1} +.ov-range-short{display:grid;grid-template-columns:1fr 1fr;gap:var(--sp-4)} +.ov-card{background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3);padding:var(--sp-4)} +.ov-card .hd{font-family:var(--font-sans);font-size:var(--fs-12);font-weight:600;letter-spacing:var(--tr-wider);text-transform:uppercase;color:var(--fg-3);margin-bottom:var(--sp-3)} +.range-grid{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:var(--sp-2);margin-bottom:var(--sp-2)} +.range-grid .edge{font-family:var(--font-mono);font-size:var(--fs-12);color:var(--fg-3)} +.range-grid .mid{font-family:var(--font-mono);font-size:var(--fs-14);color:var(--brass-bright)} +.range-rail{position:relative;height:4px;border-radius:var(--r-full);background:var(--line-2)} +.range-fill{position:absolute;left:0;top:0;height:100%;border-radius:var(--r-full);background:var(--brass)} +.range-pin{position:absolute;top:-4px;width:2px;height:12px;border-radius:var(--r-1);background:var(--brass-bright)} +.range-meta{display:flex;justify-content:space-between;font-family:var(--font-mono);font-size:var(--fs-12);color:var(--fg-3);margin-top:var(--sp-2)} +.short-grid{display:grid;grid-template-columns:repeat(2,1fr);gap:var(--sp-3)} +.short-cell{padding:var(--sp-2) var(--sp-3);border:1px solid var(--line-1);border-radius:var(--r-2);background:var(--ink-2)} +.short-cell .lbl{font-family:var(--font-sans);font-size:10px;letter-spacing:var(--tr-wider);text-transform:uppercase;color:var(--fg-4)} +.short-cell .v{font-family:var(--font-mono);font-size:var(--fs-14);color:var(--fg-1)} +.short-cell .delta-pos{color:var(--positive)} +.short-cell .delta-neg{color:var(--negative)} +.ov-controls{display:grid;grid-template-columns:1fr 1fr 1.2fr;gap:var(--sp-3);padding:var(--sp-3) var(--sp-4);background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3)} +.ctl-group{display:flex;align-items:center;gap:var(--sp-2);flex-wrap:wrap} +.ctl-lbl{font-family:var(--font-mono);font-size:10px;letter-spacing:var(--tr-wider);text-transform:uppercase;color:var(--fg-4);margin-right:var(--sp-2)} +.ctl-btn{font-family:var(--font-sans);font-size:var(--fs-12);font-weight:500;padding:5px 14px;border-radius:var(--r-2);border:1px solid var(--line-2);background:var(--ink-2);color:var(--fg-3);cursor:pointer;letter-spacing:var(--tr-wide)} +.ctl-btn:hover{background:var(--ink-3);color:var(--fg-2)} +.ctl-btn.active{background:var(--ink-3);color:var(--brass);border-color:var(--brass-deep)} +.comp-wrap{display:flex;align-items:center;gap:var(--sp-2);flex-wrap:wrap} +.comp-btn{font-family:var(--font-sans);font-size:var(--fs-12);padding:4px 10px;border-radius:var(--r-full);border:1px solid var(--line-2);background:var(--ink-2);color:var(--fg-3);cursor:pointer} +.comp-btn.active{color:var(--brass);border-color:var(--brass-deep);background:var(--ink-3)} +.comp-wrap.hidden{display:none} +.ov-chart-card{background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3);padding:var(--sp-2)} +#ov-chart{height:320px} +.ov-readout{background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-2);padding:var(--sp-3) var(--sp-4);font-family:var(--font-sans);font-size:var(--fs-13);color:var(--fg-2)} +.va-foot{font-family:var(--font-sans);font-size:var(--fs-12);color:var(--fg-3);line-height:1.6;padding:var(--sp-3) var(--sp-5);border:1px solid var(--line-1);border-radius:var(--r-2);background:var(--ink-1)} +@media (max-width:1100px){ + .ov-lede,.ov-range-short{grid-template-columns:1fr} + .ov-lede .right{grid-template-columns:1fr} + .ov-signals{grid-template-columns:1fr 1fr 1fr} + .ov-kpis{grid-template-columns:1fr 1fr 1fr} + .ov-controls{grid-template-columns:1fr} +} +</style>""" - # ── 52-week range bar ──────────────────────────────────────────────────── - _render_52w_bar(info) + ctx_html = ( + '<div class="val-ctx">' + + '<span class="sym">' + + _esc(tkr) + + "</span>" + + '<span class="name">' + + co_name + + "</span>" + + '<span class="eyebrow-ctx" style="margin-left:12px">Overview</span>' + + '<div class="meta">' + + "<span>" + + _esc(exchange or "—") + + "</span>" + + '<span class="px num">' + + price_str + + "</span>" + + '<span class="' + + chg_cls + + ' num">' + + chg_str + + "</span>" + + "</div></div>" + ) - # ── Short interest ─────────────────────────────────────────────────────── - _render_short_interest(info) + lede_html = ( + '<section class="ov-lede">' + + '<div class="left">' + + '<span class="eyebrow-lbl">Snapshot</span>' + + '<div class="ttl">Business quality, valuation, and positioning</div>' + + '<p class="sub">A fast company snapshot with valuation and risk signals, core fundamentals, 52-week positioning, short-interest context, and price versus relative performance across multiple windows.</p>' + + "</div>" + + '<div class="right">' + + '<div class="kr-source"><span class="lbl">Source</span><span class="v">yfinance</span></div>' + + '<div class="kr-source"><span class="lbl">Window</span><span class="v">market + fundamentals</span></div>' + + '<div class="kr-source"><span class="lbl">Updated</span><span class="v num">' + + _esc(meta["updated_label"]) + + "</span></div>" + + "</div></section>" + ) - st.divider() + controls_html = ( + '<section class="ov-controls">' + + '<div class="ctl-group">' + + '<span class="ctl-lbl">Period</span>' + + '<button class="ctl-btn" id="btn-p-1mo" onclick="setPeriod(\'1mo\',this)">1M</button>' + + '<button class="ctl-btn" id="btn-p-3mo" onclick="setPeriod(\'3mo\',this)">3M</button>' + + '<button class="ctl-btn" id="btn-p-6mo" onclick="setPeriod(\'6mo\',this)">6M</button>' + + '<button class="ctl-btn active" id="btn-p-1y" onclick="setPeriod(\'1y\',this)">1Y</button>' + + '<button class="ctl-btn" id="btn-p-5y" onclick="setPeriod(\'5y\',this)">5Y</button>' + + "</div>" + + '<div class="ctl-group">' + + '<span class="ctl-lbl">Mode</span>' + + '<button class="ctl-btn active" id="btn-m-price" onclick="setMode(\'price\',this)">Price</button>' + + '<button class="ctl-btn" id="btn-m-relative" onclick="setMode(\'relative\',this)">Relative</button>' + + "</div>" + + '<div class="comp-wrap hidden" id="ov-comps"></div>' + + "</section>" + ) - # ── Price chart ───────────────────────────────────────────────────────── - control_col1, control_col2 = st.columns([3, 1.4]) - with control_col1: - period_label = st.radio( - "Period", - options=list(PERIODS.keys()), - index=3, - horizontal=True, - label_visibility="collapsed", - key=f"overview_period_{ticker.upper()}", - ) - with control_col2: - chart_mode = st.radio( - "Chart mode", - options=["Price", "Relative"], - horizontal=True, - label_visibility="collapsed", - key=f"overview_chart_mode_{ticker.upper()}", - ) - period = PERIODS[period_label] + foot_html = ( + '<div class="va-foot">' + + "Overview data provided by Yahoo Finance via yfinance · Relative performance is rebased to 0% at period start · Some fields may be unavailable for certain tickers" + + "</div>" + ) - if chart_mode == "Relative": - _render_relative_chart(ticker, info, period) - return + js = ( + "<script>" + + payload_js + + meta_js + + "var activePeriod='1y';var activeMode='price';var activeComparisons=['^GSPC'];" + + "function cssVar(n){return getComputedStyle(document.documentElement).getPropertyValue(n).trim();}" + + "function withAlpha(hex,a){if(!hex||hex.charAt(0)!=='#'||hex.length!==7)return 'rgba(0,0,0,'+a+')';var r=parseInt(hex.substring(1,3),16),g=parseInt(hex.substring(3,5),16),b=parseInt(hex.substring(5,7),16);return 'rgba('+r+','+g+','+b+','+a+')';}" + + "var C_FG1=cssVar('--fg-1');var C_FG2=cssVar('--fg-2');var C_FG3=cssVar('--fg-3');var C_LINE=cssVar('--line-1');var C_BRASS=cssVar('--brass');var C_BRASS_B=cssVar('--brass-bright');var C_POS=cssVar('--positive');var C_NEG=cssVar('--negative');var C_WARN=cssVar('--warning');var C_INFO=cssVar('--info');" + + "var REL_COLORS=[C_BRASS,C_POS,C_INFO,C_WARN,C_NEG,cssVar('--oxford-light')];" + + "function asNum(v){if(v===null||v===undefined||v==='')return null;var n=Number(v);return isFinite(n)?n:null;}" + + "function esc(s){if(s===null||s===undefined)return '';return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');}" + + "function fmtInt(v){var n=asNum(v);if(n===null)return '—';return Math.round(n).toLocaleString();}" + + "function fmtRatio(v,d){var n=asNum(v);if(n===null)return '—';return n.toFixed(d);};" + + "function fmtCurrency(v,d){var n=asNum(v);if(n===null)return '—';return '$'+n.toLocaleString(undefined,{minimumFractionDigits:d,maximumFractionDigits:d});}" + + "function fmtLarge(v){var n=asNum(v);if(n===null)return '—';var a=Math.abs(n);if(a>=1e12)return (n<0?'-':'')+(a/1e12).toFixed(2)+'T';if(a>=1e9)return (n<0?'-':'')+(a/1e9).toFixed(2)+'B';if(a>=1e6)return (n<0?'-':'')+(a/1e6).toFixed(1)+'M';if(a>=1e3)return (n<0?'-':'')+(a/1e3).toFixed(1)+'K';return String(Math.round(n));}" + + "function periodLabel(p){return ({'1mo':'1M','3mo':'3M','6mo':'6M','1y':'1Y','5y':'5Y'})[p]||p;}" + + "function getSeries(sym){var b=((OVERVIEW_DATA.series||{})[activePeriod]||{});return b[sym]||[];}" + + "function toTraceData(list){var x=[],y=[];list.forEach(function(r){if(r&&r.d&&r.c!==null&&r.c!==undefined){x.push(r.d);y.push(Number(r.c));}});return {x:x,y:y};}" + + "function rebased(list){var clean=[];list.forEach(function(r){if(r&&r.d&&r.c!==null&&r.c!==undefined){clean.push({d:r.d,c:Number(r.c)});}});if(!clean.length)return {x:[],y:[]};var base=clean[0].c;if(!isFinite(base)||base<=0)return {x:[],y:[]};var x=[],y=[];clean.forEach(function(r){x.push(r.d);y.push(((r.c/base)-1)*100);});return {x:x,y:y};}" + + "function renderSignals(){var arr=OVERVIEW_DATA.signals||[];var html='';arr.forEach(function(s){var st=s.state||'neu';html+='<div class=\"ov-sig '+st+'\"><div class=\"k\">'+esc(s.k||'')+'</div><div class=\"v num\">'+esc(s.v||'—')+'</div><div class=\"d\">'+esc(s.d||'')+'</div></div>';});if(!html){html='<div class=\"ov-sig neu\"><div class=\"k\">Signals</div><div class=\"v num\">—</div><div class=\"d\">No fundamentals available.</div></div>';}document.getElementById('ov-signals').innerHTML=html;}" + + "function renderKpis(){var s=OVERVIEW_DATA.stats||{};var rows=[['Market Cap',fmtLarge(s.marketCap)],['P/E (TTM)',fmtRatio(s.trailingPE,2)],['EPS (TTM)',fmtCurrency(s.trailingEps,2)],['Volume',fmtLarge(s.volume)],['Avg Volume',fmtLarge(s.averageVolume)],['Beta',fmtRatio(s.beta,2)]];var html='';rows.forEach(function(r){html+='<div class=\"ov-kpi\"><div class=\"lbl\">'+r[0]+'</div><div class=\"v num\">'+r[1]+'</div></div>';});document.getElementById('ov-kpis').innerHTML=html;}" + + "function renderRangeCard(){var rg=OVERVIEW_DATA.range_52w||{};var lo=asNum(rg.low),hi=asNum(rg.high),px=asNum(rg.price);if(lo===null||hi===null||px===null||!(hi>lo)){document.getElementById('ov-range').innerHTML='<div class=\"short-cell\"><div class=\"lbl\">52W Range</div><div class=\"v num\">Unavailable</div></div>';return;}var pct=Math.max(0,Math.min(100,((px-lo)/(hi-lo))*100));var fromLow=((px-lo)/lo)*100;var toHigh=((hi-px)/px)*100;var html='';html+='<div class=\"range-grid\"><div class=\"edge num\">'+fmtCurrency(lo,2)+'</div><div class=\"mid num\">'+fmtCurrency(px,2)+'</div><div class=\"edge num\" style=\"text-align:right\">'+fmtCurrency(hi,2)+'</div></div>';html+='<div class=\"range-rail\"><div class=\"range-fill\" style=\"width:'+pct+'%\"></div><div class=\"range-pin\" style=\"left:calc('+pct+'% - 1px)\"></div></div>';html+='<div class=\"range-meta\"><span class=\"num\">'+fromLow.toFixed(1)+'% from low</span><span class=\"num\">'+toHigh.toFixed(1)+'% to high</span></div>';document.getElementById('ov-range').innerHTML=html;}" + + "function renderShortCard(){var s=OVERVIEW_DATA.short_interest||{};var d=s.sharesShortDeltaPct;var dCls='';var dTxt='—';if(asNum(d)!==null){dCls=d>=0?'delta-pos':'delta-neg';dTxt=(d>=0?'+':'')+(d*100).toFixed(1)+'%';}var rows=[['Short % Float',asNum(s.shortPercentOfFloat)===null?'—':(s.shortPercentOfFloat*100).toFixed(2)+'%'],['Days to Cover',asNum(s.shortRatio)===null?'—':Number(s.shortRatio).toFixed(2)],['Shares Short',fmtInt(s.sharesShort)],['Vs Prior Month',dTxt,dCls]];var html='';rows.forEach(function(r){html+='<div class=\"short-cell\"><div class=\"lbl\">'+r[0]+'</div><div class=\"v num '+(r[2]||'')+'\">'+r[1]+'</div></div>';});document.getElementById('ov-short').innerHTML=html;}" + + "function renderCompButtons(){var root=document.getElementById('ov-comps');var arr=OVERVIEW_DATA.comparisons||[];var html='<span class=\"ctl-lbl\">Compare</span>';arr.forEach(function(c){var sym=c.symbol||'';var act=activeComparisons.indexOf(sym)>=0;html+='<button class=\"comp-btn '+(act?'active':'')+'\" onclick=\"toggleComparison(\\\''+sym+'\\\',this)\">'+esc(c.label||sym)+'</button>';});root.innerHTML=html;root.classList.toggle('hidden',activeMode!=='relative'||arr.length===0);}" + + "function setPeriod(period,btn){activePeriod=period;document.querySelectorAll('[id^=btn-p-]').forEach(function(b){b.classList.remove('active');});if(btn)btn.classList.add('active');refreshAll();}" + + "function setMode(mode,btn){activeMode=mode;document.querySelectorAll('[id^=btn-m-]').forEach(function(b){b.classList.remove('active');});if(btn)btn.classList.add('active');renderCompButtons();renderChart();renderReadout();}" + + "function toggleComparison(sym,btn){var i=activeComparisons.indexOf(sym);if(i>=0){activeComparisons.splice(i,1);}else{activeComparisons.push(sym);}if(!activeComparisons.length){activeComparisons=['^GSPC'];}renderCompButtons();renderChart();renderReadout();}" + + "function renderChart(){if(typeof Plotly==='undefined'){return;}var tkr=OVERVIEW_DATA.ticker;var base=getSeries(tkr);var traces=[];if(activeMode==='price'){var p=toTraceData(base);traces.push({x:p.x,y:p.y,type:'scatter',mode:'lines',name:tkr,line:{color:C_BRASS,width:2.2},fill:'tozeroy',fillcolor:withAlpha(C_BRASS,0.10)});}else{var p2=rebased(base);traces.push({x:p2.x,y:p2.y,type:'scatter',mode:'lines',name:tkr,line:{color:C_BRASS,width:2.4}});var idx=1;activeComparisons.forEach(function(sym){if(sym===tkr)return;var rr=rebased(getSeries(sym));if(!rr.x.length)return;var label=sym;var comps=OVERVIEW_DATA.comparisons||[];for(var i=0;i<comps.length;i+=1){if(comps[i].symbol===sym){label=comps[i].label||sym;break;}}traces.push({x:rr.x,y:rr.y,type:'scatter',mode:'lines',name:label,line:{color:REL_COLORS[idx%REL_COLORS.length],width:1.8}});idx+=1;});}var layout={height:320,margin:{l:52,r:14,t:14,b:34},paper_bgcolor:'rgba(0,0,0,0)',plot_bgcolor:'rgba(0,0,0,0)',hovermode:'x unified',font:{family:'IBM Plex Mono',color:C_FG3},xaxis:{showgrid:false,zeroline:false,tickfont:{family:'IBM Plex Mono'}},yaxis:{showgrid:true,gridcolor:C_LINE,zeroline:(activeMode==='relative'),zerolinecolor:C_LINE,tickprefix:(activeMode==='price'?'$':''),ticksuffix:(activeMode==='relative'?'%':''),tickformat:(activeMode==='relative'?'.1f':',.2f')},legend:{orientation:'h',x:0,y:1.12,bgcolor:'rgba(0,0,0,0)',font:{family:'IBM Plex Mono',color:C_FG2,size:11}}};Plotly.react('ov-chart',traces,layout,{displayModeBar:false,responsive:true});}" + + "function renderReadout(){var rg=OVERVIEW_DATA.range_52w||{};var lo=asNum(rg.low),hi=asNum(rg.high),px=asNum(rg.price);var txt='';if(lo!==null&&hi!==null&&px!==null&&hi>lo&&px>0){var above=((px-lo)/lo)*100;var below=((hi-px)/px)*100;txt='Stock is '+above.toFixed(1)+'% above 52-week low and '+below.toFixed(1)+'% below 52-week high.';}if(activeMode==='relative'){var t=rebased(getSeries(OVERVIEW_DATA.ticker));var tLast=t.y.length?t.y[t.y.length-1]:null;var sp=rebased(getSeries('^GSPC'));var spLast=sp.y.length?sp.y[sp.y.length-1]:null;if(tLast!==null&&spLast!==null){txt='Relative over '+periodLabel(activePeriod)+': '+OVERVIEW_DATA.ticker+' '+(tLast>=0?'+':'')+tLast.toFixed(1)+'% vs S&P 500 '+(spLast>=0?'+':'')+spLast.toFixed(1)+'%.';}}if(!txt){txt='Data is limited for this ticker and selected period, but chart controls remain available.';}document.getElementById('ov-readout').textContent=txt;}" + + "function refreshAll(){renderSignals();renderKpis();renderRangeCard();renderShortCard();renderCompButtons();renderChart();renderReadout();}" + + "function bootOverview(){refreshAll();if(typeof Plotly==='undefined'){setTimeout(refreshAll,350);setTimeout(refreshAll,900);}}" + + "if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',bootOverview);}else{bootOverview();}" + + "</script>" + ) - hist = get_price_history(ticker, period=period) - if hist.empty: - st.warning("No price history available.") - return + height = 1480 - fig = go.Figure() - fig.add_trace(go.Scatter( - x=hist.index, - y=hist["Close"], - mode="lines", - name="Close", - line=dict(color="#C2AA7A", width=2), - fill="tozeroy", - fillcolor="rgba(79,142,247,0.08)", - )) - fig.update_layout( - margin=dict(l=0, r=0, t=10, b=0), - xaxis=dict(showgrid=False, zeroline=False), - yaxis=dict(showgrid=True, gridcolor="rgba(255,255,255,0.05)", zeroline=False), - plot_bgcolor="rgba(0,0,0,0)", - paper_bgcolor="rgba(0,0,0,0)", - hovermode="x unified", - height=320, + doc = ( + "<!doctype html><html><head><meta charset='utf-8'>" + + "<meta name='viewport' content='width=device-width, initial-scale=1'>" + + fonts_link + + plotly_cdn + + _ROOT + + _OV_CSS + + "</head><body>" + + '<div class="ov-wrap">' + + ctx_html + + '<div class="ov-body">' + + lede_html + + '<section class="ov-signals" id="ov-signals"></section>' + + '<section class="ov-kpis" id="ov-kpis"></section>' + + '<section class="ov-range-short">' + + '<div class="ov-card"><div class="hd">52-Week Positioning</div><div id="ov-range"></div></div>' + + '<div class="ov-card"><div class="hd">Short Interest</div><div class="short-grid" id="ov-short"></div></div>' + + "</section>" + + controls_html + + '<section class="ov-chart-card"><div id="ov-chart"></div></section>' + + '<section class="ov-readout" id="ov-readout"></section>' + + foot_html + + "</div></div>" + + js + + "</body></html>" ) - st.plotly_chart(fig, width="stretch") + + components.html(doc, height=height, scrolling=False) |
