From f3181e8b27f910d50453de9ad5f8d967014edf23 Mon Sep 17 00:00:00 2001 From: Tyler Date: Sat, 16 May 2026 00:05:08 -0700 Subject: Redesign insiders tab with client-side HTML view --- components/insiders.py | 470 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 358 insertions(+), 112 deletions(-) (limited to 'components/insiders.py') diff --git a/components/insiders.py b/components/insiders.py index 1087061..785a094 100644 --- a/components/insiders.py +++ b/components/insiders.py @@ -1,15 +1,20 @@ -"""Insider transactions — recent buys/sells with summary and detail table.""" +"""Insider transactions tab rendered as a client-side HTML surface.""" +from html import escape as _esc + import pandas as pd -import plotly.graph_objects as go import streamlit as st -from datetime import datetime, timedelta +import streamlit.components.v1 as components + +from services.data_service import ( + get_company_info, + get_insider_transactions, + get_latest_price, +) -from services.data_service import get_insider_transactions -from utils.formatters import fmt_large +_XMAP = {"NYQ": "NYSE", "NMS": "NASDAQ", "NGM": "NASDAQ", "NCM": "NASDAQ", "ASE": "AMEX"} def _classify(text: str) -> str: - """Return 'Buy', 'Sell', or 'Other' from the transaction text.""" t = str(text or "").lower() if any(k in t for k in ("sale", "sold", "disposition")): return "Sell" @@ -19,125 +24,366 @@ def _classify(text: str) -> str: def render_insiders(ticker: str): - with st.spinner("Loading insider transactions…"): - df = get_insider_transactions(ticker) + import json as _json - if df.empty: + info = get_company_info(ticker) or {} + price = get_latest_price(ticker) + df = get_insider_transactions(ticker) + + if df is None or df.empty: st.info("No insider transaction data available for this ticker.") return - # Normalise columns — yfinance returns: Shares, URL, Text, Insider, Position, - # Transaction, Start Date, Ownership, Value df = df.copy() - df["Direction"] = df["Text"].apply(_classify) + if "Start Date" in df.columns: + df["_date"] = pd.to_datetime(df["Start Date"], errors="coerce") + else: + df["_date"] = pd.NaT - # Parse dates - def _to_dt(val): + def _num(val): try: - return pd.to_datetime(val) - except Exception: - return pd.NaT + n = float(val) + except (TypeError, ValueError): + return None + if pd.isna(n): + return None + return n - df["_date"] = df["Start Date"].apply(_to_dt) + rows = [] + for _, row in df.iterrows(): + dt = row.get("_date") + date_str = dt.strftime("%Y-%m-%d") if pd.notna(dt) else None + month_str = dt.strftime("%Y-%m") if pd.notna(dt) else None - # ── Summary: last 6 months ──────────────────────────────────────────── - cutoff = pd.Timestamp(datetime.now() - timedelta(days=180)) - recent = df[df["_date"] >= cutoff] + shares_num = _num(row.get("Shares")) + if shares_num is not None and abs(shares_num - round(shares_num)) < 1e-9: + shares_num = int(round(shares_num)) - buys = recent[recent["Direction"] == "Buy"] - sells = recent[recent["Direction"] == "Sell"] + value_num = _num(row.get("Value")) - def _total_value(subset): - try: - return subset["Value"].dropna().astype(float).sum() - except Exception: - return 0.0 - - buy_val = _total_value(buys) - sell_val = _total_value(sells) - - st.markdown("**Insider Activity — Last 6 Months**") - c1, c2, c3, c4 = st.columns(4) - c1.metric("Buy Transactions", len(buys)) - c2.metric("Total Bought", fmt_large(buy_val) if buy_val else "—") - c3.metric("Sell Transactions", len(sells)) - c4.metric("Total Sold", fmt_large(sell_val) if sell_val else "—") - - # Monthly bar chart - if not recent.empty: - monthly: dict[str, dict] = {} - for _, row in recent.iterrows(): - if pd.isna(row["_date"]): - continue - key = row["_date"].strftime("%Y-%m") - monthly.setdefault(key, {"Buy": 0.0, "Sell": 0.0}) - try: - val = float(row["Value"]) if pd.notna(row["Value"]) else 0.0 - except (TypeError, ValueError): - val = 0.0 - if row["Direction"] in ("Buy", "Sell"): - monthly[key][row["Direction"]] += val - - months = sorted(monthly.keys()) - if months: - fig = go.Figure() - fig.add_trace(go.Bar( - x=months, y=[monthly[m]["Buy"] / 1e6 for m in months], - name="Buys", marker_color="#4F8C5E", - )) - fig.add_trace(go.Bar( - x=months, y=[-monthly[m]["Sell"] / 1e6 for m in months], - name="Sells", marker_color="#B5494B", - )) - fig.update_layout( - title="Monthly Insider Net Activity ($M)", - barmode="relative", - yaxis_title="Value ($M)", - margin=dict(l=0, r=0, t=40, b=0), - height=280, - legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1), - ) - st.plotly_chart(fig, width="stretch") - - st.divider() - - # ── Transaction table ───────────────────────────────────────────────── - st.markdown("**All Transactions**") - - filter_col, _ = st.columns([1, 3]) - with filter_col: - direction_filter = st.selectbox( - "Filter", options=["All", "Buy", "Sell"], index=0, key="insider_filter" + rows.append( + { + "date": date_str, + "month": month_str, + "insider": str(row.get("Insider")) if pd.notna(row.get("Insider")) else None, + "position": str(row.get("Position")) if pd.notna(row.get("Position")) else None, + "direction": _classify(row.get("Text")), + "shares": shares_num, + "value": value_num, + "ownership": str(row.get("Ownership")) if pd.notna(row.get("Ownership")) else None, + "text": str(row.get("Text")) if pd.notna(row.get("Text")) else None, + "transaction": str(row.get("Transaction")) if pd.notna(row.get("Transaction")) else None, + } ) - filtered = df if direction_filter == "All" else df[df["Direction"] == direction_filter] + rows.sort(key=lambda r: (r.get("date") is not None, r.get("date") or ""), reverse=True) - if filtered.empty: - st.info("No transactions match the current filter.") - return + latest_date = rows[0].get("date") if rows else None + n_rows = max(len(rows), 18) + height = 1320 + n_rows * 32 + + def _safe_float(val): + try: + return float(val) + except (TypeError, ValueError): + return None - display = pd.DataFrame({ - "Date": filtered["Start Date"].astype(str).str[:10], - "Insider": filtered["Insider"], - "Position": filtered["Position"], - "Type": filtered["Direction"], - "Shares": filtered["Shares"].apply( - lambda v: f"{int(v):,}" if pd.notna(v) else "—" - ), - "Value": filtered["Value"].apply( - lambda v: fmt_large(float(v)) if pd.notna(v) and float(v) > 0 else "—" - ), - }).reset_index(drop=True) - - def _color_type(row): - if row["Type"] == "Buy": - return [""] * 3 + ["background-color: #15241A; color: #4F8C5E"] + [""] * 2 - if row["Type"] == "Sell": - return [""] * 3 + ["background-color: #2A1517; color: #B5494B"] + [""] * 2 - return [""] * len(row) - - st.dataframe( - display.style.apply(_color_type, axis=1), - width="stretch", - hide_index=True, + current_price = info.get("currentPrice") or info.get("regularMarketPrice") or price + prev_close = info.get("previousClose") + cur_num = _safe_float(current_price) + prev_num = _safe_float(prev_close) + if cur_num is not None and prev_num is not None and prev_num > 0: + chg_pct = (cur_num - prev_num) / prev_num * 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 = "" + + exchange_raw = str(info.get("exchange") or "") + exchange = _XMAP.get(exchange_raw, exchange_raw) or "—" + co_name = _esc(info.get("longName") or info.get("shortName") or ticker.upper()) + price_str = "${:,.2f}".format(cur_num) if cur_num is not None else "—" + + meta = {"ticker": ticker.upper(), "latest_date": latest_date, "transactions": len(rows)} + rows_js = "const INSIDERS=" + _json.dumps(rows) + ";" + meta_js = "const INSIDER_META=" + _json.dumps(meta) + ";" + + plotly_cdn = "" + _ROOT = ( + "" ) + fonts_link = ( + "" + "" + ) + + _IN_CSS = """""" + + ctx_html = ( + '
' + '' + + _esc(ticker.upper()) + + "" + + '' + + co_name + + "" + + 'Insiders' + + '
' + + "" + + _esc(exchange) + + "" + + '' + + price_str + + "" + + '' + + chg_str + + "" + + "
" + ) + + lede_html = ( + '
' + + '
' + + 'Ownership' + + '
Who is buying, who is selling
' + + '

Recent insider transactions for ' + + _esc(ticker.upper()) + + ", grouped by direction, month, and participant. Use the controls below to isolate buy or sell activity and inspect the underlying filings feed from Yahoo Finance via yfinance.

" + + "
" + + '
' + + '
Sourceyfinance
' + + '
Windowrecent filings
' + + '
Transactions' + + str(len(rows)) + + "
" + + "
" + ) + + controls_html = ( + '
' + + '
' + + 'Range' + + '' + + '' + + '' + + "
" + + '
' + + 'Direction' + + '' + + '' + + '' + + "
" + + "
" + ) + + foot_html = ( + '
' + + "Insider transaction data provided by Yahoo Finance via yfinance · Values reflect reported transaction value when available · Classification into Buy / Sell uses transaction text heuristics" + + "
" + ) + + js = ( + "" + ) + + doc = ( + "" + + plotly_cdn + + fonts_link + + _ROOT + + _IN_CSS + + "
" + + ctx_html + + '
' + + lede_html + + controls_html + + '
' + + '
' + + '
Monthly net activity
' + + '
Top participants
' + + "
" + + '
' + + '
' + + foot_html + + "
" + + js + + "" + ) + + components.html(doc, height=height, scrolling=False) -- cgit v1.3-2-g0d8e