290 lines
12 KiB
Python
290 lines
12 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 yfinance as yf
|
|
import pandas as pd
|
|
import sys
|
|
import os
|
|
from datetime import datetime
|
|
|
|
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'
|
|
"""
|
|
print(f"\n{'='*80}")
|
|
print(f"INSIDER TRANSACTIONS ANALYSIS: {ticker.upper()}")
|
|
print(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:
|
|
print(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
|
|
# ============================================================
|
|
print(f"\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()):
|
|
print(f"\n### {position}")
|
|
print("-" * 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}"
|
|
print(f" {row['Year']} | {row['Transaction']:15} | {row['Shares']:>12,.0f} shares | {value_str}")
|
|
|
|
# ============================================================
|
|
# BY INSIDER
|
|
# ============================================================
|
|
print(f"\n\n{'='*80}")
|
|
print("INSIDER TRANSACTIONS BY PERSON")
|
|
print(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()):
|
|
print(f"\n### {str(person)}")
|
|
print("-" * 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]
|
|
print(f" {row['Year']} | {pos_str:25} | {row['Transaction']:15} | {row['Shares']:>12,.0f} shares | {value_str}")
|
|
else:
|
|
print(f"Warning: Could not find 'Insider' or 'Name' column in data. Columns: {data.columns.tolist()}")
|
|
|
|
# ============================================================
|
|
# YEARLY SUMMARY
|
|
# ============================================================
|
|
print(f"\n\n{'='*80}")
|
|
print("YEARLY SUMMARY BY TRANSACTION TYPE")
|
|
print(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):
|
|
print(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}"
|
|
print(f" {row['Transaction']:15} | {row['Shares']:>12,.0f} shares | {value_str}")
|
|
|
|
# ============================================================
|
|
# OVERALL SENTIMENT
|
|
# ============================================================
|
|
print(f"\n\n{'='*80}")
|
|
print("INSIDER SENTIMENT SUMMARY")
|
|
print(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
|
|
}])
|
|
|
|
print(f"Total Sales: {sales_count:>5} transactions | ${total_sales:>15,.0f}")
|
|
print(f"Total Purchases: {purchases_count:>5} transactions | ${total_purchases:>15,.0f}")
|
|
|
|
if sentiment == "BULLISH":
|
|
print(f"\n⚡ BULLISH: Insiders are net BUYERS (${net_value:,.0f} net buying)")
|
|
elif sentiment == "BEARISH":
|
|
print(f"\n⚠️ BEARISH: Significant insider SELLING (${-net_value:,.0f} net selling)")
|
|
elif sentiment == "SLIGHTLY_BEARISH":
|
|
print(f"\n⚠️ SLIGHTLY BEARISH: More selling than buying (${-net_value:,.0f} net selling)")
|
|
else:
|
|
print(f"\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)
|
|
print(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)
|
|
print(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)
|
|
print(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)
|
|
print(f"📁 Saved: {sentiment_file}")
|
|
|
|
except Exception as e:
|
|
print(f"Error analyzing {ticker}: {str(e)}")
|
|
|
|
return result
|
|
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) < 2:
|
|
print("Usage: python analyze_insider_transactions.py TICKER [TICKER2 ...] [--csv] [--output-dir DIR]")
|
|
print("Example: python analyze_insider_transactions.py AAPL TSLA NVDA")
|
|
print(" python analyze_insider_transactions.py AAPL --csv")
|
|
print(" 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:
|
|
print("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)
|
|
print(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)
|
|
print(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)
|
|
print(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)
|
|
print(f"📁 Combined: {combined_sentiment_file}")
|