TradingAgents/scripts/analyze_insider_transaction...

356 lines
13 KiB
Python

#!/usr/bin/env python3
"""
Insider Transactions Aggregation Script
Aggregates insider transactions by:
- Position (CEO, CFO, Director, etc.)
- Year
- Transaction Type (Sale, Purchase, Gift, Grant/Exercise)
Usage:
python scripts/analyze_insider_transactions.py AAPL
python scripts/analyze_insider_transactions.py TSLA NVDA MSFT
python scripts/analyze_insider_transactions.py AAPL --csv # Save to CSV
"""
import os
import sys
from datetime import datetime
from pathlib import Path
import pandas as pd
import yfinance as yf
sys.path.insert(0, str(Path(__file__).parent.parent))
from tradingagents.utils.logger import get_logger
logger = get_logger(__name__)
def classify_transaction(text):
"""Classify transaction type based on text description."""
if pd.isna(text) or text == "":
return "Grant/Exercise"
text_lower = str(text).lower()
if "sale" in text_lower:
return "Sale"
elif "purchase" in text_lower or "buy" in text_lower:
return "Purchase"
elif "gift" in text_lower:
return "Gift"
else:
return "Other"
def analyze_insider_transactions(ticker: str, save_csv: bool = False, output_dir: str = None):
"""Analyze and aggregate insider transactions for a given ticker.
Args:
ticker: Stock ticker symbol
save_csv: Whether to save results to CSV files
output_dir: Directory to save CSV files (default: current directory)
Returns:
Dictionary with DataFrames: 'by_position', 'yearly', 'sentiment'
"""
logger.info(f"\n{'='*80}")
logger.info(f"INSIDER TRANSACTIONS ANALYSIS: {ticker.upper()}")
logger.info(f"{'='*80}")
result = {"by_position": None, "by_person": None, "yearly": None, "sentiment": None}
try:
ticker_obj = yf.Ticker(ticker.upper())
data = ticker_obj.insider_transactions
if data is None or data.empty:
logger.warning(f"No insider transaction data found for {ticker}")
return result
# Parse transaction type and year
data["Transaction"] = data["Text"].apply(classify_transaction)
data["Year"] = pd.to_datetime(data["Start Date"]).dt.year
# ============================================================
# BY POSITION, YEAR, TRANSACTION TYPE
# ============================================================
logger.info("\n## BY POSITION\n")
agg = (
data.groupby(["Position", "Year", "Transaction"])
.agg({"Shares": "sum", "Value": "sum"})
.reset_index()
)
agg["Ticker"] = ticker.upper()
result["by_position"] = agg
for position in sorted(agg["Position"].unique()):
logger.info(f"\n### {position}")
logger.info("-" * 50)
pos_data = agg[agg["Position"] == position].sort_values(
["Year", "Transaction"], ascending=[False, True]
)
for _, row in pos_data.iterrows():
value_str = (
f"${row['Value']:>15,.0f}"
if pd.notna(row["Value"]) and row["Value"] > 0
else f"{'N/A':>16}"
)
logger.info(
f" {row['Year']} | {row['Transaction']:15} | {row['Shares']:>12,.0f} shares | {value_str}"
)
# ============================================================
# BY INSIDER
# ============================================================
logger.info(f"\n\n{'='*80}")
logger.info("INSIDER TRANSACTIONS BY PERSON")
logger.info(f"{'='*80}")
insider_col = "Insider"
if insider_col not in data.columns and "Name" in data.columns:
insider_col = "Name"
if insider_col in data.columns:
agg_person = (
data.groupby([insider_col, "Position", "Year", "Transaction"])
.agg({"Shares": "sum", "Value": "sum"})
.reset_index()
)
agg_person["Ticker"] = ticker.upper()
result["by_person"] = agg_person
for person in sorted(agg_person[insider_col].unique()):
logger.info(f"\n### {str(person)}")
logger.info("-" * 50)
p_data = agg_person[agg_person[insider_col] == person].sort_values(
["Year", "Transaction"], ascending=[False, True]
)
for _, row in p_data.iterrows():
value_str = (
f"${row['Value']:>15,.0f}"
if pd.notna(row["Value"]) and row["Value"] > 0
else f"{'N/A':>16}"
)
pos_str = str(row["Position"])[:25]
logger.info(
f" {row['Year']} | {pos_str:25} | {row['Transaction']:15} | {row['Shares']:>12,.0f} shares | {value_str}"
)
else:
logger.warning(
f"Warning: Could not find 'Insider' or 'Name' column in data. Columns: {data.columns.tolist()}"
)
# ============================================================
# YEARLY SUMMARY
# ============================================================
logger.info(f"\n\n{'='*80}")
logger.info("YEARLY SUMMARY BY TRANSACTION TYPE")
logger.info(f"{'='*80}")
yearly = (
data.groupby(["Year", "Transaction"])
.agg({"Shares": "sum", "Value": "sum"})
.reset_index()
)
yearly["Ticker"] = ticker.upper()
result["yearly"] = yearly
for year in sorted(yearly["Year"].unique(), reverse=True):
logger.info(f"\n{year}:")
year_data = yearly[yearly["Year"] == year].sort_values("Transaction")
for _, row in year_data.iterrows():
value_str = (
f"${row['Value']:>15,.0f}"
if pd.notna(row["Value"]) and row["Value"] > 0
else f"{'N/A':>16}"
)
logger.info(
f" {row['Transaction']:15} | {row['Shares']:>12,.0f} shares | {value_str}"
)
# ============================================================
# OVERALL SENTIMENT
# ============================================================
logger.info(f"\n\n{'='*80}")
logger.info("INSIDER SENTIMENT SUMMARY")
logger.info(f"{'='*80}\n")
total_sales = data[data["Transaction"] == "Sale"]["Value"].sum()
total_purchases = data[data["Transaction"] == "Purchase"]["Value"].sum()
sales_count = len(data[data["Transaction"] == "Sale"])
purchases_count = len(data[data["Transaction"] == "Purchase"])
net_value = total_purchases - total_sales
# Determine sentiment
if total_purchases > total_sales:
sentiment = "BULLISH"
elif total_sales > total_purchases * 2:
sentiment = "BEARISH"
elif total_sales > total_purchases:
sentiment = "SLIGHTLY_BEARISH"
else:
sentiment = "NEUTRAL"
result["sentiment"] = pd.DataFrame(
[
{
"Ticker": ticker.upper(),
"Total_Sales_Count": sales_count,
"Total_Sales_Value": total_sales,
"Total_Purchases_Count": purchases_count,
"Total_Purchases_Value": total_purchases,
"Net_Value": net_value,
"Sentiment": sentiment,
}
]
)
logger.info(f"Total Sales: {sales_count:>5} transactions | ${total_sales:>15,.0f}")
logger.info(
f"Total Purchases: {purchases_count:>5} transactions | ${total_purchases:>15,.0f}"
)
if sentiment == "BULLISH":
logger.info(f"\n⚡ BULLISH: Insiders are net BUYERS (${net_value:,.0f} net buying)")
elif sentiment == "BEARISH":
logger.info(
f"\n⚠️ BEARISH: Significant insider SELLING (${-net_value:,.0f} net selling)"
)
elif sentiment == "SLIGHTLY_BEARISH":
logger.info(
f"\n⚠️ SLIGHTLY BEARISH: More selling than buying (${-net_value:,.0f} net selling)"
)
else:
logger.info("\n📊 NEUTRAL: Balanced insider activity")
# Save to CSV if requested
if save_csv:
if output_dir is None:
output_dir = os.getcwd()
os.makedirs(output_dir, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
# Save by position
by_pos_file = os.path.join(
output_dir, f"insider_by_position_{ticker.upper()}_{timestamp}.csv"
)
agg.to_csv(by_pos_file, index=False)
logger.info(f"\n📁 Saved: {by_pos_file}")
# Save by person
if result["by_person"] is not None:
by_person_file = os.path.join(
output_dir, f"insider_by_person_{ticker.upper()}_{timestamp}.csv"
)
result["by_person"].to_csv(by_person_file, index=False)
logger.info(f"📁 Saved: {by_person_file}")
# Save yearly summary
yearly_file = os.path.join(
output_dir, f"insider_yearly_{ticker.upper()}_{timestamp}.csv"
)
yearly.to_csv(yearly_file, index=False)
logger.info(f"📁 Saved: {yearly_file}")
# Save sentiment summary
sentiment_file = os.path.join(
output_dir, f"insider_sentiment_{ticker.upper()}_{timestamp}.csv"
)
result["sentiment"].to_csv(sentiment_file, index=False)
logger.info(f"📁 Saved: {sentiment_file}")
except Exception as e:
logger.error(f"Error analyzing {ticker}: {str(e)}")
return result
if __name__ == "__main__":
if len(sys.argv) < 2:
logger.info(
"Usage: python analyze_insider_transactions.py TICKER [TICKER2 ...] [--csv] [--output-dir DIR]"
)
logger.info("Example: python analyze_insider_transactions.py AAPL TSLA NVDA")
logger.info(" python analyze_insider_transactions.py AAPL --csv")
logger.info(
" python analyze_insider_transactions.py AAPL --csv --output-dir ./output"
)
sys.exit(1)
# Parse arguments
args = sys.argv[1:]
save_csv = "--csv" in args
output_dir = None
if "--output-dir" in args:
idx = args.index("--output-dir")
if idx + 1 < len(args):
output_dir = args[idx + 1]
args = args[:idx] + args[idx + 2 :]
else:
logger.error("Error: --output-dir requires a directory path")
sys.exit(1)
if save_csv:
args.remove("--csv")
tickers = [t for t in args if not t.startswith("--")]
# Collect all results for combined CSV
all_by_position = []
all_by_person = []
all_yearly = []
all_sentiment = []
for ticker in tickers:
result = analyze_insider_transactions(ticker, save_csv=save_csv, output_dir=output_dir)
if result["by_position"] is not None:
all_by_position.append(result["by_position"])
if result["by_person"] is not None:
all_by_person.append(result["by_person"])
if result["yearly"] is not None:
all_yearly.append(result["yearly"])
if result["sentiment"] is not None:
all_sentiment.append(result["sentiment"])
# If multiple tickers and CSV mode, also save combined files
if save_csv and len(tickers) > 1:
if output_dir is None:
output_dir = os.getcwd()
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
if all_by_position:
combined_pos = pd.concat(all_by_position, ignore_index=True)
combined_pos_file = os.path.join(
output_dir, f"insider_by_position_combined_{timestamp}.csv"
)
combined_pos.to_csv(combined_pos_file, index=False)
logger.info(f"\n📁 Combined: {combined_pos_file}")
if all_by_person:
combined_person = pd.concat(all_by_person, ignore_index=True)
combined_person_file = os.path.join(
output_dir, f"insider_by_person_combined_{timestamp}.csv"
)
combined_person.to_csv(combined_person_file, index=False)
logger.info(f"📁 Combined: {combined_person_file}")
if all_yearly:
combined_yearly = pd.concat(all_yearly, ignore_index=True)
combined_yearly_file = os.path.join(
output_dir, f"insider_yearly_combined_{timestamp}.csv"
)
combined_yearly.to_csv(combined_yearly_file, index=False)
logger.info(f"📁 Combined: {combined_yearly_file}")
if all_sentiment:
combined_sentiment = pd.concat(all_sentiment, ignore_index=True)
combined_sentiment_file = os.path.join(
output_dir, f"insider_sentiment_combined_{timestamp}.csv"
)
combined_sentiment.to_csv(combined_sentiment_file, index=False)
logger.info(f"📁 Combined: {combined_sentiment_file}")