diff options
| author | Tyler <tyler@tylerhoang.xyz> | 2026-05-16 00:05:08 -0700 |
|---|---|---|
| committer | Tyler <tyler@tylerhoang.xyz> | 2026-05-16 00:05:08 -0700 |
| commit | f3181e8b27f910d50453de9ad5f8d967014edf23 (patch) | |
| tree | c54eccc8fa7c4dac85a4056b460f600f0ea26ece /components/insiders.py | |
| parent | 0d888203cbc4dc596d0c05cedfeabe8785b263fc (diff) | |
Redesign insiders tab with client-side HTML view
Diffstat (limited to 'components/insiders.py')
| -rw-r--r-- | components/insiders.py | 454 |
1 files changed, 350 insertions, 104 deletions
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 + + info = get_company_info(ticker) or {} + price = get_latest_price(ticker) + df = get_insider_transactions(ticker) - if df.empty: + 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 + 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, + } + ) - buy_val = _total_value(buys) - sell_val = _total_value(sells) + rows.sort(key=lambda r: (r.get("date") is not None, r.get("date") or ""), reverse=True) - 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 "—") + latest_date = rows[0].get("date") if rows else None + n_rows = max(len(rows), 18) + height = 1320 + n_rows * 32 - # 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 + def _safe_float(val): + try: + return float(val) + except (TypeError, ValueError): + return None - 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") + 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 = "" - st.divider() + 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 "—" - # ── Transaction table ───────────────────────────────────────────────── - st.markdown("**All Transactions**") + 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) + ";" - filter_col, _ = st.columns([1, 3]) - with filter_col: - direction_filter = st.selectbox( - "Filter", options=["All", "Buy", "Sell"], index=0, key="insider_filter" - ) + 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>" + ) + 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'>" + ) - filtered = df if direction_filter == "All" else df[df["Direction"] == direction_filter] + _IN_CSS = """<style> +.in-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)} +.in-body{padding:var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-5)} +.in-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)} +.in-lede .left{display:flex;flex-direction:column;gap:var(--sp-2)} +.in-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} +.in-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} +.in-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} +.in-controls{display:grid;grid-template-columns:1fr 1fr;gap:var(--sp-4);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)} +.in-kpis{display:grid;grid-template-columns:repeat(5,1fr);gap:var(--sp-3)} +.in-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)} +.in-kpi .lbl{font-family:var(--font-mono);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-4);font-weight:500} +.in-kpi .v{font-family:var(--font-mono);font-size:var(--fs-24);line-height:1.1;font-variant-numeric:tabular-nums;color:var(--fg-1)} +.in-kpi .v.pos{color:var(--positive)} +.in-kpi .v.neg{color:var(--negative)} +.in-kpi .v.neu{color:var(--fg-3)} +.in-analysis-grid{display:grid;grid-template-columns:1.25fr 1fr;gap:var(--sp-4)} +.in-card{background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3);padding:var(--sp-3)} +.in-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);padding:0 var(--sp-1) var(--sp-2)} +#in-monthly-chart{height:308px} +.part-grid-head,.part-grid-row{display:grid;grid-template-columns:1.8fr 1.2fr 0.8fr 1fr} +.part-grid-head{border-bottom:1px solid var(--line-1)} +.part-grid-head div{padding:9px var(--sp-2);font-family:var(--font-sans);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-4);font-weight:600} +.part-grid-head div:nth-child(3),.part-grid-head div:nth-child(4),.part-grid-row div:nth-child(3),.part-grid-row div:nth-child(4){text-align:right} +.part-grid-row{border-bottom:1px solid var(--line-1)} +.part-grid-row:last-child{border-bottom:none} +.part-grid-row div{padding:10px var(--sp-2);font-family:var(--font-mono);font-size:var(--fs-13);font-variant-numeric:tabular-nums;color:var(--fg-2)} +.part-grid-row .name{font-family:var(--font-sans);font-size:var(--fs-13);color:var(--fg-1)} +.part-grid-row .pos{color:var(--fg-3);font-family:var(--font-sans)} +.part-grid-row .net.pos{color:var(--positive)} +.part-grid-row .net.neg{color:var(--negative)} +.in-empty{padding:var(--sp-6);text-align:center;color:var(--fg-3);font-size:var(--fs-14);font-family:var(--font-sans)} +.in-readout{background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-2);padding:var(--sp-3) var(--sp-4);font-size:var(--fs-13);line-height:1.5;color:var(--fg-2)} +.in-table-card{background:var(--ink-1);border:1px solid var(--line-1);border-radius:var(--r-3);overflow:hidden} +.in-table-head{padding:var(--sp-3) var(--sp-4);border-bottom:1px solid var(--line-1);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)} +.in-grid-head,.in-grid-row{display:grid;grid-template-columns:0.9fr 1.5fr 1.1fr 0.8fr 0.9fr 1fr 0.7fr} +.in-grid-head{background:var(--ink-2);border-bottom:1px solid var(--line-1)} +.in-grid-head div{padding:8px var(--sp-3);font-family:var(--font-sans);font-size:10px;text-transform:uppercase;letter-spacing:var(--tr-wider);color:var(--fg-4);font-weight:600} +.in-grid-head div:nth-child(5),.in-grid-head div:nth-child(6),.in-grid-row div:nth-child(5),.in-grid-row div:nth-child(6){text-align:right} +.in-grid-row{border-bottom:1px solid var(--line-1);position:relative} +.in-grid-row:last-child{border-bottom:none} +.in-grid-row.buy{border-left:3px solid var(--positive)} +.in-grid-row.sell{border-left:3px solid var(--negative)} +.in-grid-row div{padding:10px var(--sp-3);font-family:var(--font-sans);font-size:var(--fs-13);color:var(--fg-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.in-grid-row .mono{font-family:var(--font-mono);font-variant-numeric:tabular-nums} +.type-badge{display:inline-flex;align-items:center;justify-content:center;padding:2px 8px;border-radius:var(--r-full);font-family:var(--font-sans);font-size:11px;font-weight:600} +.type-badge.buy{background:var(--positive-bg);color:var(--positive)} +.type-badge.sell{background:var(--negative-bg);color:var(--negative)} +.type-badge.other{background:var(--warning-bg);color:var(--warning)} +.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){ + .in-lede,.in-analysis-grid{grid-template-columns:1fr} + .in-lede .right{grid-template-columns:1fr} + .in-controls{grid-template-columns:1fr} + .in-kpis{grid-template-columns:1fr 1fr} + .in-grid-head,.in-grid-row{grid-template-columns:1fr 1.4fr 1fr 0.9fr 1fr 1fr 0.8fr} +} +</style>""" - if filtered.empty: - st.info("No transactions match the current filter.") - return + ctx_html = ( + '<div class="val-ctx">' + '<span class="sym">' + + _esc(ticker.upper()) + + "</span>" + + '<span class="name">' + + co_name + + "</span>" + + '<span class="eyebrow-ctx" style="margin-left:12px">Insiders</span>' + + '<div class="meta">' + + "<span>" + + _esc(exchange) + + "</span>" + + '<span class="px num">' + + price_str + + "</span>" + + '<span class="' + + chg_cls + + ' num">' + + chg_str + + "</span>" + + "</div></div>" + ) - 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) + lede_html = ( + '<section class="in-lede">' + + '<div class="left">' + + '<span class="eyebrow-lbl">Ownership</span>' + + '<div class="ttl">Who is buying, who is selling</div>' + + '<p class="sub">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.</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">recent filings</span></div>' + + '<div class="kr-source"><span class="lbl">Transactions</span><span class="v num">' + + str(len(rows)) + + "</span></div>" + + "</div></section>" + ) - 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) + controls_html = ( + '<div class="in-controls">' + + '<div class="ctl-group">' + + '<span class="ctl-lbl">Range</span>' + + '<button class="ctl-btn active" data-range="6m" onclick="setRange(\'6m\',this)">6M</button>' + + '<button class="ctl-btn" data-range="12m" onclick="setRange(\'12m\',this)">12M</button>' + + '<button class="ctl-btn" data-range="all" onclick="setRange(\'all\',this)">All</button>' + + "</div>" + + '<div class="ctl-group">' + + '<span class="ctl-lbl">Direction</span>' + + '<button class="ctl-btn active" data-dir="All" onclick="setDirection(\'All\',this)">All</button>' + + '<button class="ctl-btn" data-dir="Buy" onclick="setDirection(\'Buy\',this)">Buy</button>' + + '<button class="ctl-btn" data-dir="Sell" onclick="setDirection(\'Sell\',this)">Sell</button>' + + "</div>" + + "</div>" + ) - st.dataframe( - display.style.apply(_color_type, axis=1), - width="stretch", - hide_index=True, + foot_html = ( + '<div class="va-foot">' + + "<span>Insider transaction data provided by Yahoo Finance via yfinance · Values reflect reported transaction value when available · Classification into Buy / Sell uses transaction text heuristics</span>" + + "</div>" ) + + js = ( + "<script>" + + rows_js + + meta_js + + "var activeRange='6m';var activeDirection='All';" + + "function cssVar(n){return getComputedStyle(document.documentElement).getPropertyValue(n).trim();}" + + "var C_POS=cssVar('--positive');var C_NEG=cssVar('--negative');var C_LINE=cssVar('--line-1');var C_FG3=cssVar('--fg-3');" + + "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 fmtMoney(v){var n=asNum(v);if(n===null)return '—';var a=Math.abs(n);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 (n<0?'-$':'$')+a.toFixed(0);}" + + "function fmtMoneyM(v){var n=asNum(v);if(n===null)return 0;return n/1e6;}" + + "function monthsBack(n){var d=new Date();d.setHours(0,0,0,0);d.setMonth(d.getMonth()-n);return d;}" + + "function parseDate(s){if(!s)return null;var d=new Date(s+'T00:00:00');return isNaN(d.getTime())?null:d;}" + + "function applyFilters(){return (INSIDERS||[]).filter(function(r){" + + "if(activeRange!=='all'){var cutoff=monthsBack(activeRange==='6m'?6:12);var dt=parseDate(r.date);if(!dt||dt<cutoff)return false;}" + + "if(activeDirection!=='All'&&r.direction!==activeDirection)return false;" + + "return true;});}" + + "function renderKPIs(rows){var bTx=0,sTx=0,bVal=0,sVal=0,bHas=false,sHas=false;" + + "rows.forEach(function(r){var v=asNum(r.value);if(r.direction==='Buy'){bTx+=1;if(v!==null){bVal+=v;bHas=true;}}if(r.direction==='Sell'){sTx+=1;if(v!==null){sVal+=v;sHas=true;}}});" + + "var net=(bHas||sHas)?(bVal-sVal):null;var netCls='neu';if(net!==null&&net>0)netCls='pos';if(net!==null&&net<0)netCls='neg';" + + "var html='';" + + "html+='<div class=\"in-kpi\"><div class=\"lbl\">Buy Transactions</div><div class=\"v\">'+bTx.toLocaleString()+'</div></div>';" + + "html+='<div class=\"in-kpi\"><div class=\"lbl\">Buy Value</div><div class=\"v\">'+(bHas?fmtMoney(bVal):'—')+'</div></div>';" + + "html+='<div class=\"in-kpi\"><div class=\"lbl\">Sell Transactions</div><div class=\"v\">'+sTx.toLocaleString()+'</div></div>';" + + "html+='<div class=\"in-kpi\"><div class=\"lbl\">Sell Value</div><div class=\"v\">'+(sHas?fmtMoney(sVal):'—')+'</div></div>';" + + "html+='<div class=\"in-kpi\"><div class=\"lbl\">Net Activity</div><div class=\"v '+netCls+'\">'+(net===null?'—':fmtMoney(net))+'</div></div>';" + + "document.getElementById('in-kpis').innerHTML=html;}" + + "function renderMonthlyChart(rows){if(typeof Plotly==='undefined')return;" + + "var map={};rows.forEach(function(r){if(!r.month)return;if(!map[r.month])map[r.month]={buy:0,sell:0};var v=asNum(r.value);if(v===null)return;if(r.direction==='Buy')map[r.month].buy+=v;else if(r.direction==='Sell')map[r.month].sell+=v;});" + + "var months=Object.keys(map).sort();var buy=[];var sell=[];" + + "months.forEach(function(m){buy.push(fmtMoneyM(map[m].buy));sell.push(-fmtMoneyM(map[m].sell));});" + + "var data=[{x:months,y:buy,name:'Buys',type:'bar',marker:{color:C_POS}},{x:months,y:sell,name:'Sells',type:'bar',marker:{color:C_NEG}}];" + + "var layout={plot_bgcolor:'rgba(0,0,0,0)',paper_bgcolor:'rgba(0,0,0,0)',margin:{l:52,r:12,t:18,b:44},height:300,barmode:'relative',font:{family:'IBM Plex Mono',color:C_FG3},xaxis:{title:'Month',gridcolor:C_LINE,zerolinecolor:C_LINE},yaxis:{title:'Value ($M)',gridcolor:C_LINE,zerolinecolor:C_LINE},legend:{orientation:'h',y:-0.25,x:0}};" + + "Plotly.react('in-monthly-chart',data,layout,{displayModeBar:false,responsive:true});}" + + "function renderParticipants(rows){var m={};rows.forEach(function(r){var n=(r.insider||'Unknown').trim()||'Unknown';if(!m[n])m[n]={insider:n,position:r.position||'—',tx:0,buy:0,sell:0};m[n].tx+=1;var v=asNum(r.value);if(v!==null){if(r.direction==='Buy')m[n].buy+=v;else if(r.direction==='Sell')m[n].sell+=v;}});" + + "var arr=Object.keys(m).map(function(k){var o=m[k];o.net=o.buy-o.sell;o.abs=Math.abs(o.net);return o;});" + + "arr.sort(function(a,b){if(b.abs!==a.abs)return b.abs-a.abs;return b.tx-a.tx;});arr=arr.slice(0,8);" + + "var head='<div class=\"part-grid-head\"><div>Insider</div><div>Position</div><div>Txns</div><div>Net Value</div></div>';" + + "if(!arr.length){document.getElementById('in-participants').innerHTML=head+'<div class=\"in-empty\">No participant rows for current filters.</div>';return;}" + + "var body='';arr.forEach(function(r){var cls=r.net>0?'pos':(r.net<0?'neg':'');body+='<div class=\"part-grid-row\">';" + + "body+='<div class=\"name\">'+esc(r.insider)+'</div>';" + + "body+='<div class=\"pos\">'+esc(r.position||'—')+'</div>';" + + "body+='<div>'+String(r.tx)+'</div>';" + + "body+='<div class=\"net '+cls+'\">'+fmtMoney(r.net)+'</div>';" + + "body+='</div>';});" + + "document.getElementById('in-participants').innerHTML=head+body;}" + + "function renderReadout(rows){var buyTx=0,sellTx=0,buyVal=0,sellVal=0,buySet={};var latest=null;" + + "rows.forEach(function(r){if(r.direction==='Buy'){buyTx+=1;if(r.insider)buySet[r.insider]=1;var bv=asNum(r.value);if(bv!==null)buyVal+=bv;}if(r.direction==='Sell'){sellTx+=1;var sv=asNum(r.value);if(sv!==null)sellVal+=sv;}if(r.date&&(latest===null||r.date>latest))latest=r.date;});" + + "var msg1='Activity appears mixed across the selected filters.';" + + "if(sellVal>buyVal){msg1='Sell activity dominated by '+fmtMoney(sellVal-buyVal)+' across '+sellTx+' filings.';}" + + "else if(buyVal>sellVal){msg1='Buy activity dominated by '+fmtMoney(buyVal-sellVal)+' across '+buyTx+' filings.';}" + + "var msg2='Buying activity was concentrated in '+Object.keys(buySet).length+' participants.';" + + "var msg3='Most recent filing: '+(latest||INSIDER_META.latest_date||'—')+'.';" + + "document.getElementById('in-readout').innerHTML=esc(msg1)+' · '+esc(msg2)+' · '+esc(msg3);}" + + "function renderTable(rows){var head='<div class=\"in-table-head\">Recent transactions</div><div class=\"in-grid-head\"><div>Date</div><div>Insider</div><div>Position</div><div>Type</div><div>Shares</div><div>Value</div><div>Ownership</div></div>';" + + "if(!rows.length){document.getElementById('in-table').innerHTML=head+'<div class=\"in-empty\">No transactions match current filters.</div>';return;}" + + "var body='';rows.forEach(function(r){var dir=r.direction||'Other';var rowCls=(dir==='Buy'?' buy':(dir==='Sell'?' sell':''));var badgeCls=(dir==='Buy'?'buy':(dir==='Sell'?'sell':'other'));" + + "body+='<div class=\"in-grid-row'+rowCls+'\">';" + + "body+='<div class=\"mono\">'+esc(r.date||'—')+'</div>';" + + "body+='<div>'+esc(r.insider||'—')+'</div>';" + + "body+='<div>'+esc(r.position||'—')+'</div>';" + + "body+='<div><span class=\"type-badge '+badgeCls+'\">'+esc(dir)+'</span></div>';" + + "body+='<div class=\"mono\">'+fmtInt(r.shares)+'</div>';" + + "body+='<div class=\"mono\">'+(asNum(r.value)===null?'—':fmtMoney(asNum(r.value)))+'</div>';" + + "body+='<div class=\"mono\">'+esc(r.ownership||'—')+'</div>';" + + "body+='</div>';});" + + "document.getElementById('in-table').innerHTML=head+body;}" + + "function refreshAll(){var filtered=applyFilters();renderKPIs(filtered);renderMonthlyChart(filtered);renderParticipants(filtered);renderReadout(filtered);renderTable(filtered);}" + + "function setRange(range,btn){activeRange=range;document.querySelectorAll('.ctl-btn[data-range]').forEach(function(b){b.classList.remove('active');});if(btn)btn.classList.add('active');refreshAll();}" + + "function setDirection(direction,btn){activeDirection=direction;document.querySelectorAll('.ctl-btn[data-dir]').forEach(function(b){b.classList.remove('active');});if(btn)btn.classList.add('active');refreshAll();}" + + "function bootInsiders(){refreshAll();if(typeof Plotly==='undefined'){setTimeout(refreshAll,350);setTimeout(refreshAll,950);}}" + + "if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',bootInsiders);}else{bootInsiders();}" + + "</script>" + ) + + doc = ( + "<!doctype html><html><head><meta charset='utf-8'>" + + plotly_cdn + + fonts_link + + _ROOT + + _IN_CSS + + "</head><body><div class='in-wrap'>" + + ctx_html + + '<div class="in-body">' + + lede_html + + controls_html + + '<div id="in-kpis" class="in-kpis"></div>' + + '<div class="in-analysis-grid">' + + '<div class="in-card"><div class="in-card-hd">Monthly net activity</div><div id="in-monthly-chart"></div></div>' + + '<div class="in-card"><div class="in-card-hd">Top participants</div><div id="in-participants"></div></div>' + + "</div>" + + '<div id="in-readout" class="in-readout"></div>' + + '<div class="in-table-card"><div id="in-table"></div></div>' + + foot_html + + "</div></div>" + + js + + "</body></html>" + ) + + components.html(doc, height=height, scrolling=False) |
