aboutsummaryrefslogtreecommitdiff
path: root/components/insiders.py
diff options
context:
space:
mode:
authorTyler <tyler@tylerhoang.xyz>2026-03-29 15:41:42 -0700
committerTyler <tyler@tylerhoang.xyz>2026-03-29 15:41:42 -0700
commit3e1324eff69e4b121f85223758b872c7fb5dc027 (patch)
treeb9e34a461460036c4aac1fdc7dab33763ef255ce /components/insiders.py
parent4fdcb4ce0f00bc8f62d50ba5d352dd2fe01cd7e7 (diff)
Migrate insiders and filings from FMP to yfinance
FMP v3 insider-trading and sec_filings endpoints are legacy-gated. Switch to yfinance (t.insider_transactions, t.sec_filings) which provides the same data for free with no API key required. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'components/insiders.py')
-rw-r--r--components/insiders.py190
1 files changed, 87 insertions, 103 deletions
diff --git a/components/insiders.py b/components/insiders.py
index 05561ba..354ffef 100644
--- a/components/insiders.py
+++ b/components/insiders.py
@@ -4,121 +4,102 @@ import plotly.graph_objects as go
import streamlit as st
from datetime import datetime, timedelta
-from services.fmp_service import get_insider_transactions
+from services.data_service import get_insider_transactions
from utils.formatters import fmt_currency, fmt_large
-def _parse_date(date_str: str) -> datetime | None:
- for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"):
- try:
- return datetime.strptime(str(date_str)[:19], fmt)
- except ValueError:
- continue
- return None
-
-
-def _classify(row: dict) -> str:
- """Return 'Buy' or 'Sell' from the transaction type field."""
- tx = str(row.get("transactionType") or row.get("acquistionOrDisposition") or "").strip()
- buy_keywords = ("purchase", "p -", "p-", "acquisition", "a -", "a-", "grant", "award", "exercise")
- sell_keywords = ("sale", "s -", "s-", "disposition", "d -", "d-")
- tx_lower = tx.lower()
- if any(k in tx_lower for k in sell_keywords):
+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 tx_lower for k in buy_keywords):
- return "Buy"
- # Fallback: FMP often uses acquistionOrDisposition = "A" or "D"
- aod = str(row.get("acquistionOrDisposition") or "").strip().upper()
- if aod == "D":
- return "Sell"
- if aod == "A":
+ 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…"):
- raw = get_insider_transactions(ticker)
+ df = get_insider_transactions(ticker)
- if not raw:
- st.info("No insider transaction data available. Requires FMP API key.")
+ if df.empty:
+ st.info("No insider transaction data available for this ticker.")
return
- # Build enriched rows
- rows = []
- for item in raw:
- direction = _classify(item)
- shares = item.get("securitiesTransacted") or item.get("shares") or 0
- price = item.get("price") or 0.0
+ # 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:
- value = float(shares) * float(price)
- except (TypeError, ValueError):
- value = 0.0
- date_obj = _parse_date(str(item.get("transactionDate") or item.get("filingDate") or ""))
- rows.append({
- "date": date_obj,
- "date_str": str(item.get("transactionDate") or "")[:10],
- "name": item.get("reportingName") or item.get("insiderName") or "—",
- "title": item.get("typeOfOwner") or item.get("title") or "—",
- "direction": direction,
- "shares": shares,
- "price": price,
- "value": value,
- "filing_url": item.get("link") or item.get("secLink") or "",
- })
+ return pd.to_datetime(val)
+ except Exception:
+ return pd.NaT
+
+ df["_date"] = df["Start Date"].apply(_to_dt)
# ── Summary: last 6 months ────────────────────────────────────────────
- cutoff = datetime.now() - timedelta(days=180)
- recent = [r for r in rows if r["date"] and r["date"] >= cutoff]
+ 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
- buys = [r for r in recent if r["direction"] == "Buy"]
- sells = [r for r in recent if r["direction"] == "Sell"]
- total_buy_val = sum(r["value"] for r in buys)
- total_sell_val = sum(r["value"] for r in sells)
+ 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(total_buy_val) if total_buy_val else "—")
+ c2.metric("Total Bought", fmt_large(buy_val) if buy_val else "—")
c3.metric("Sell Transactions", len(sells))
- c4.metric("Total Sold", fmt_large(total_sell_val) if total_sell_val else "—")
+ c4.metric("Total Sold", fmt_large(sell_val) if sell_val else "—")
- # Net buy/sell bar chart (monthly)
- if recent:
+ # Monthly bar chart
+ if not recent.empty:
monthly: dict[str, dict] = {}
- for r in recent:
- if r["date"]:
- key = r["date"].strftime("%Y-%m")
- monthly.setdefault(key, {"Buy": 0.0, "Sell": 0.0})
- if r["direction"] in ("Buy", "Sell"):
- monthly[key][r["direction"]] += r["value"]
+ 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())
- buy_vals = [monthly[m]["Buy"] / 1e6 for m in months]
- sell_vals = [-monthly[m]["Sell"] / 1e6 for m in months]
-
- fig = go.Figure()
- fig.add_trace(go.Bar(
- x=months, y=buy_vals,
- name="Buys",
- marker_color="#2ecc71",
- ))
- fig.add_trace(go.Bar(
- x=months, y=sell_vals,
- 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)
+ 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()
@@ -131,31 +112,34 @@ def render_insiders(ticker: str):
"Filter", options=["All", "Buy", "Sell"], index=0, key="insider_filter"
)
- filtered = rows if direction_filter == "All" else [r for r in rows if r["direction"] == direction_filter]
+ filtered = df if direction_filter == "All" else df[df["Direction"] == direction_filter]
- if not filtered:
+ if filtered.empty:
st.info("No transactions match the current filter.")
return
- df = pd.DataFrame([{
- "Date": r["date_str"],
- "Insider": r["name"],
- "Title": r["title"],
- "Type": r["direction"],
- "Shares": f"{int(r['shares']):,}" if r["shares"] else "—",
- "Price": fmt_currency(r["price"]) if r["price"] else "—",
- "Value": fmt_large(r["value"]) if r["value"] else "—",
- } for r in filtered])
+ 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 [""] * 2 + ["background-color: rgba(46,204,113,0.15)"] + [""] * 4
+ return [""] * 3 + ["background-color: rgba(46,204,113,0.15)"] + [""] * 2
if row["Type"] == "Sell":
- return [""] * 2 + ["background-color: rgba(231,76,60,0.15)"] + [""] * 4
+ return [""] * 3 + ["background-color: rgba(231,76,60,0.15)"] + [""] * 2
return [""] * len(row)
st.dataframe(
- df.style.apply(_color_type, axis=1),
+ display.style.apply(_color_type, axis=1),
use_container_width=True,
hide_index=True,
)