"""Insider transactions — recent buys/sells with summary and detail table.""" import pandas as pd import plotly.graph_objects as go import streamlit as st from datetime import datetime, timedelta from services.data_service import get_insider_transactions from utils.formatters import fmt_large 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" if any(k in t for k in ("purchase", "bought", "acquisition", "grant", "award", "exercise")): return "Buy" return "Other" def render_insiders(ticker: str): with st.spinner("Loading insider transactions…"): df = get_insider_transactions(ticker) if 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) # Parse dates def _to_dt(val): try: return pd.to_datetime(val) except Exception: return pd.NaT df["_date"] = df["Start Date"].apply(_to_dt) # ── Summary: last 6 months ──────────────────────────────────────────── cutoff = pd.Timestamp(datetime.now() - timedelta(days=180)) recent = df[df["_date"] >= cutoff] buys = recent[recent["Direction"] == "Buy"] sells = recent[recent["Direction"] == "Sell"] 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="#2ecc71", )) fig.add_trace(go.Bar( x=months, y=[-monthly[m]["Sell"] / 1e6 for m in months], name="Sells", marker_color="#e74c3c", )) fig.update_layout( title="Monthly Insider Net Activity ($M)", barmode="relative", yaxis_title="Value ($M)", plot_bgcolor="rgba(0,0,0,0)", paper_bgcolor="rgba(0,0,0,0)", 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, use_container_width=True) 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" ) filtered = df if direction_filter == "All" else df[df["Direction"] == direction_filter] if filtered.empty: st.info("No transactions match the current filter.") return 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: rgba(46,204,113,0.15)"] + [""] * 2 if row["Type"] == "Sell": return [""] * 3 + ["background-color: rgba(231,76,60,0.15)"] + [""] * 2 return [""] * len(row) st.dataframe( display.style.apply(_color_type, axis=1), use_container_width=True, hide_index=True, )