1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
|
"""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_currency, 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,
)
|