Files
edgartools/investor_holdings.py
2025-12-09 12:13:01 +01:00

290 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
import os
import json
import hashlib
# Check if virtual environment is activated
if not os.environ.get('VIRTUAL_ENV'):
print("Virtual environment is not activated.")
print("To activate: . venv/bin/activate")
print("Then run: python investor_holdings.py")
exit(1)
import pandas as pd
from edgar import Company, set_identity, set_local_storage_path, use_local_storage
# Step-by-step implementation of local storage from https://www.edgartools.io/speedup-jobs-with-local-storage/
# Step-by-step implementation of local storage from https://www.edgartools.io/speedup-jobs-with-local-storage/
# Enable local storage for caching filings
LOCAL_STORAGE_PATH = os.path.abspath("./edgar_cache")
os.makedirs(LOCAL_STORAGE_PATH, exist_ok=True)
use_local_storage(LOCAL_STORAGE_PATH)
# Set your identity (required by SEC) - after local storage
set_identity("your.email@example.com")
# Cache directory
CACHE_DIR = 'cache'
os.makedirs(CACHE_DIR, exist_ok=True)
def get_cache_key(name, ticker, inv_type, num_filings):
key = f"{name}_{ticker}_{inv_type}_{num_filings}"
return hashlib.md5(key.encode()).hexdigest() + '.json'
def load_cache(cache_file):
if os.path.exists(cache_file):
with open(cache_file, 'r') as f:
return json.load(f)
return None
def save_cache(cache_file, data):
with open(cache_file, 'w') as f:
json.dump(data, f, indent=2)
def get_holdings(ticker_or_cik, name, num_filings=1):
cache_file = os.path.join(CACHE_DIR, get_cache_key(name, ticker_or_cik, '13f', num_filings))
cached_data = load_cache(cache_file)
if cached_data:
print(f"\n{name} - 13F Holdings (from cache):")
for entry in cached_data:
print(f"\n{name} ({entry['company_name']}) - 13F Holdings as of {entry['report_period']}:")
print("=" * 60)
for holding in entry['top_holdings']:
print(holding)
return cached_data
try:
company = Company(ticker_or_cik)
if company.not_found:
print(f"{name}: Company not found")
return None
# Get last num_filings 13F filings
filings_13f = company.get_filings(form="13F-HR").head(num_filings)
if not filings_13f:
print(f"{name}: No 13F filings found")
return None
data_to_cache = []
for i, filing_13f in enumerate(filings_13f):
# Get holdings data
holdings_obj = filing_13f.obj()
if not holdings_obj.has_infotable():
print(f"{name} - Filing {i+1}: No holdings data")
continue
holdings_df = holdings_obj.infotable
report_period = holdings_obj.report_period
print(f"\n{name} ({company.name}) - 13F Holdings as of {report_period}:")
print("=" * 60)
holdings_list = []
# Ensure SharesPrnAmount is numeric
holdings_df['SharesPrnAmount'] = pd.to_numeric(holdings_df['SharesPrnAmount'], errors='coerce')
# Separate long and short positions
# Puts are bearish (short exposure), Calls are bullish (long exposure)
put_call_col = 'PutCall' if 'PutCall' in holdings_df.columns else None
shares_type_col = 'SharesPrnType' if 'SharesPrnType' in holdings_df.columns else None
if put_call_col:
long_positions = holdings_df[(holdings_df[put_call_col] != 'Put') & (holdings_df.get(shares_type_col, '') != 'SH')]
short_positions = holdings_df[(holdings_df[put_call_col] == 'Put') | (holdings_df.get(shares_type_col, '') == 'SH')]
else:
long_positions = holdings_df[holdings_df.get(shares_type_col, '') != 'SH']
short_positions = holdings_df[holdings_df.get(shares_type_col, '') == 'SH']
# Display top 10 long holdings by value
if len(long_positions) > 0:
top_long = long_positions.nlargest(10, 'Value')
print("\nTop Long Positions:")
for idx, row in top_long.iterrows():
shares = int(row['SharesPrnAmount']) if pd.notna(row['SharesPrnAmount']) else 'N/A'
put_call = row.get('PutCall', '')
title = row.get('TitleOfClass', '')
position_type = put_call if put_call else (title if title else 'Shares')
line = f"{row['Issuer']}: ${row['Value']/1e6:.1f}M ({shares} shares, {position_type})"
print(line)
holdings_list.append(f"Long: {line}")
# Display top 10 short positions by absolute value
if len(short_positions) > 0:
short_positions = short_positions.copy()
short_positions['AbsValue'] = short_positions['Value'].abs()
top_short = short_positions.nlargest(10, 'AbsValue')
print("\nTop Short Positions:")
for idx, row in top_short.iterrows():
shares = int(row['SharesPrnAmount']) if pd.notna(row['SharesPrnAmount']) else 'N/A'
line = f"{row['Issuer']}: ${row['Value']/1e6:.1f}M ({shares} shares)"
print(line)
holdings_list.append(f"Short: {line}")
else:
# If no 'SH' type, check for negative shares as shorts
potential_shorts = holdings_df[holdings_df['SharesPrnAmount'] < 0]
if len(potential_shorts) > 0:
potential_shorts = potential_shorts.copy()
potential_shorts['AbsValue'] = potential_shorts['Value'].abs()
top_short = potential_shorts.nlargest(10, 'AbsValue')
print("\nTop Short Positions (inferred from negative shares):")
for idx, row in top_short.iterrows():
shares = int(row['SharesPrnAmount']) if pd.notna(row['SharesPrnAmount']) else 'N/A'
line = f"{row['Issuer']}: ${row['Value']/1e6:.1f}M ({shares} shares)"
print(line)
holdings_list.append(f"Short: {line}")
else:
print("\nNo short positions reported.")
data_to_cache.append({
'report_period': str(report_period),
'company_name': company.name,
'top_holdings': holdings_list
})
save_cache(cache_file, data_to_cache)
return data_to_cache
except Exception as e:
print(f"Error getting holdings for {name}: {e}")
return None
def get_insider_transactions(ticker, insider_name=None, num_filings=5):
cache_file = os.path.join(CACHE_DIR, get_cache_key(insider_name or 'all', ticker, 'insider', num_filings))
cached_data = load_cache(cache_file)
if cached_data:
print(f"\nAll insiders found in recent Form 4 filings for {cached_data['company_name']} ({ticker}) (from cache):")
for insider in cached_data['all_insiders']:
print(f" - {insider}")
if insider_name:
if cached_data['found_transactions']:
print(f"\nInsider Transactions for {insider_name} at {cached_data['company_name']} ({ticker}) (from cache):")
print("=" * 80)
for tx in cached_data['found_transactions'][:10]:
print(f"Date: {tx['date']}, Code: {tx['code']}, Shares: {tx['shares']}, Price: ${tx['price']}, Amount: ${tx['amount']}")
else:
print(f"No transactions found for {insider_name} at {ticker}")
return cached_data['found_transactions']
try:
company = Company(ticker)
if company.not_found:
print(f"Company {ticker}: Not found")
return None
# Get recent Form 4 filings
filings_4 = company.get_filings(form="4").head(num_filings)
if not filings_4:
print(f"No Form 4 filings found for {ticker}")
return None
all_insiders = set()
found_transactions = []
for filing in filings_4:
form4 = filing.obj()
if hasattr(form4, 'transactions') and form4.transactions is not None:
if hasattr(form4, 'reporting_owner') and form4.reporting_owner:
owner_name = form4.reporting_owner.name
all_insiders.add(owner_name)
if insider_name and insider_name.lower() in owner_name.lower():
for transaction in form4.transactions:
found_transactions.append({
'date': str(filing.filing_date),
'owner': owner_name,
'security': transaction.security_title,
'code': transaction.transaction_code,
'shares': str(transaction.transaction_shares),
'price': str(transaction.transaction_price),
'amount': str(transaction.transaction_amount)
})
print(f"\nAll insiders found in recent Form 4 filings for {company.name} ({ticker}):")
for insider in sorted(all_insiders):
print(f" - {insider}")
if insider_name:
if found_transactions:
print(f"\nInsider Transactions for {insider_name} at {company.name} ({ticker}):")
print("=" * 80)
for tx in found_transactions[:10]: # Limit to 10 most recent
print(f"Date: {tx['date']}, Code: {tx['code']}, Shares: {tx['shares']}, Price: ${tx['price']}, Amount: ${tx['amount']}")
else:
print(f"No transactions found for {insider_name} at {ticker}")
data_to_cache = {
'company_name': company.name,
'all_insiders': sorted(list(all_insiders)),
'found_transactions': found_transactions
}
save_cache(cache_file, data_to_cache)
return found_transactions
except Exception as e:
print(f"Error getting insider transactions: {e}")
return None
# Load investors from external file
with open('investors.json', 'r') as f:
config = json.load(f)
investors = config['investors']
def select_investors():
print("\nAvailable Investors:")
for i, inv in enumerate(investors, 1):
print(f"{i}. {inv['name']} ({inv['type'].upper()})")
print("0. All investors")
print("q. Quit")
while True:
choice = input("\nSelect investor(s) (comma-separated numbers, 'all', or 'q' to quit): ").strip().lower()
if choice == 'q':
return []
elif choice == 'all' or choice == '0':
return investors
else:
try:
indices = [int(x.strip()) - 1 for x in choice.split(',')]
selected = [investors[i] for i in indices if 0 <= i < len(investors)]
if selected:
return selected
else:
print("Invalid selection. Try again.")
except ValueError:
print("Invalid input. Enter numbers separated by commas, 'all', or 'q'.")
if __name__ == "__main__":
import sys
if len(sys.argv) > 1:
try:
idx = int(sys.argv[1]) - 1
if 0 <= idx < len(investors):
selected_investors = [investors[idx]]
else:
print("Invalid index")
selected_investors = []
except ValueError:
print("Invalid argument")
selected_investors = []
else:
selected_investors = select_investors()
if not selected_investors:
print("Exiting.")
exit(0)
for investor in selected_investors:
name = investor['name']
ticker = investor['ticker']
inv_type = investor['type']
num_filings = investor.get('filings', 1)
if inv_type == '13f':
get_holdings(ticker, name, num_filings)
elif inv_type == 'insider':
get_insider_transactions(ticker, name, num_filings)
print("\n" + "="*80)
print("Processing complete.")