aboutsummaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
authorTyler <tyler@tylerhoang.xyz>2026-05-16 00:05:08 -0700
committerTyler <tyler@tylerhoang.xyz>2026-05-16 00:05:08 -0700
commitf3181e8b27f910d50453de9ad5f8d967014edf23 (patch)
treec54eccc8fa7c4dac85a4056b460f600f0ea26ece /components
parent0d888203cbc4dc596d0c05cedfeabe8785b263fc (diff)
Redesign insiders tab with client-side HTML view
Diffstat (limited to 'components')
-rw-r--r--components/insiders.py454
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}"
+ + "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)