451 lines
16 KiB
Python
451 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Institutional Flow Tracker - Main Screening Script
|
|
|
|
Screens for stocks with significant institutional ownership changes by analyzing
|
|
13F filings data. Identifies stocks where smart money is accumulating or distributing.
|
|
|
|
Usage:
|
|
python3 track_institutional_flow.py --top 50 --min-change-percent 10
|
|
python3 track_institutional_flow.py --sector Technology --min-institutions 20
|
|
python3 track_institutional_flow.py --api-key YOUR_KEY --output results.json
|
|
|
|
Requirements:
|
|
- FMP API key (set FMP_API_KEY environment variable or pass --api-key)
|
|
- Free tier: 250 requests/day (sufficient for ~40-50 stocks)
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
from datetime import datetime
|
|
from typing import List, Dict, Optional
|
|
import time
|
|
|
|
try:
|
|
import requests
|
|
except ImportError:
|
|
print("Error: 'requests' library not installed. Install with: pip install requests")
|
|
sys.exit(1)
|
|
|
|
|
|
class InstitutionalFlowTracker:
|
|
"""Track institutional ownership changes across stocks"""
|
|
|
|
def __init__(self, api_key: str):
|
|
self.api_key = api_key
|
|
self.base_url = "https://financialmodelingprep.com/api/v3"
|
|
self.base_url_v4 = "https://financialmodelingprep.com/api/v4"
|
|
|
|
def get_stock_screener(
|
|
self,
|
|
market_cap_min: int = 1000000000,
|
|
limit: int = 500
|
|
) -> List[Dict]:
|
|
"""Get list of stocks meeting market cap criteria"""
|
|
url = f"{self.base_url}/stock-screener"
|
|
params = {
|
|
"marketCapMoreThan": market_cap_min,
|
|
"limit": limit,
|
|
"apikey": self.api_key
|
|
}
|
|
|
|
try:
|
|
response = requests.get(url, params=params, timeout=30)
|
|
response.raise_for_status()
|
|
return response.json()
|
|
except requests.exceptions.RequestException as e:
|
|
print(f"Error fetching stock screener: {e}")
|
|
return []
|
|
|
|
def get_institutional_holders(self, symbol: str) -> List[Dict]:
|
|
"""Get institutional holders for a specific stock"""
|
|
url = f"{self.base_url}/institutional-holder/{symbol}"
|
|
params = {"apikey": self.api_key}
|
|
|
|
try:
|
|
response = requests.get(url, params=params, timeout=30)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
return data if isinstance(data, list) else []
|
|
except requests.exceptions.RequestException as e:
|
|
print(f"Error fetching institutional holders for {symbol}: {e}")
|
|
return []
|
|
|
|
def calculate_ownership_metrics(
|
|
self,
|
|
symbol: str,
|
|
company_name: str,
|
|
market_cap: float
|
|
) -> Optional[Dict]:
|
|
"""Calculate institutional ownership metrics for a stock"""
|
|
holders = self.get_institutional_holders(symbol)
|
|
|
|
if not holders or len(holders) < 2:
|
|
return None
|
|
|
|
# Group by date to get quarterly snapshots
|
|
quarters = {}
|
|
for holder in holders:
|
|
date = holder.get('dateReported', '')
|
|
if not date:
|
|
continue
|
|
if date not in quarters:
|
|
quarters[date] = []
|
|
quarters[date].append(holder)
|
|
|
|
# Need at least 2 quarters for comparison
|
|
if len(quarters) < 2:
|
|
return None
|
|
|
|
# Get most recent 2 quarters
|
|
sorted_quarters = sorted(quarters.keys(), reverse=True)
|
|
current_q = sorted_quarters[0]
|
|
previous_q = sorted_quarters[1]
|
|
|
|
current_holders = quarters[current_q]
|
|
previous_holders = quarters[previous_q]
|
|
|
|
# Calculate aggregate metrics
|
|
current_total_shares = sum(h.get('totalShares', 0) for h in current_holders)
|
|
previous_total_shares = sum(h.get('totalShares', 0) for h in previous_holders)
|
|
|
|
# Calculate changes
|
|
shares_change = current_total_shares - previous_total_shares
|
|
if previous_total_shares > 0:
|
|
percent_change = (shares_change / previous_total_shares) * 100
|
|
else:
|
|
percent_change = 0
|
|
|
|
# Count institutions
|
|
current_count = len(current_holders)
|
|
previous_count = len(previous_holders)
|
|
institution_change = current_count - previous_count
|
|
|
|
# Calculate dollar value (approximate)
|
|
# Note: This is approximate as we don't have exact prices
|
|
current_value = sum(h.get('totalInvested', 0) for h in current_holders)
|
|
previous_value = sum(h.get('totalInvested', 0) for h in previous_holders)
|
|
value_change = current_value - previous_value
|
|
|
|
# Get top holders
|
|
top_holders = sorted(
|
|
current_holders,
|
|
key=lambda x: x.get('totalShares', 0),
|
|
reverse=True
|
|
)[:10]
|
|
|
|
top_holder_names = [
|
|
{
|
|
'name': h.get('holder', 'Unknown'),
|
|
'shares': h.get('totalShares', 0),
|
|
'change': h.get('change', 0)
|
|
}
|
|
for h in top_holders
|
|
]
|
|
|
|
return {
|
|
'symbol': symbol,
|
|
'company_name': company_name,
|
|
'market_cap': market_cap,
|
|
'current_quarter': current_q,
|
|
'previous_quarter': previous_q,
|
|
'current_total_shares': current_total_shares,
|
|
'previous_total_shares': previous_total_shares,
|
|
'shares_change': shares_change,
|
|
'percent_change': round(percent_change, 2),
|
|
'current_institution_count': current_count,
|
|
'previous_institution_count': previous_count,
|
|
'institution_count_change': institution_change,
|
|
'current_value': current_value,
|
|
'previous_value': previous_value,
|
|
'value_change': value_change,
|
|
'top_holders': top_holder_names
|
|
}
|
|
|
|
def screen_stocks(
|
|
self,
|
|
min_market_cap: int = 1000000000,
|
|
min_change_percent: float = 10.0,
|
|
min_institutions: int = 10,
|
|
sector: Optional[str] = None,
|
|
top: int = 50,
|
|
sort_by: str = 'ownership_change'
|
|
) -> List[Dict]:
|
|
"""Screen for stocks with significant institutional changes"""
|
|
|
|
print(f"Fetching stocks with market cap >= ${min_market_cap:,}...")
|
|
stocks = self.get_stock_screener(market_cap_min=min_market_cap, limit=500)
|
|
|
|
if not stocks:
|
|
print("No stocks found in screener")
|
|
return []
|
|
|
|
# Filter by sector if specified
|
|
if sector:
|
|
stocks = [s for s in stocks if s.get('sector', '').lower() == sector.lower()]
|
|
print(f"Filtered to {len(stocks)} stocks in {sector} sector")
|
|
|
|
print(f"Analyzing institutional ownership for {len(stocks)} stocks...")
|
|
print("This may take a few minutes. Please wait...\n")
|
|
|
|
results = []
|
|
for i, stock in enumerate(stocks, 1):
|
|
symbol = stock.get('symbol', '')
|
|
company_name = stock.get('companyName', '')
|
|
market_cap = stock.get('marketCap', 0)
|
|
|
|
if i % 10 == 0:
|
|
print(f"Progress: {i}/{len(stocks)} stocks analyzed...")
|
|
|
|
# Rate limiting: max 5 requests per second
|
|
time.sleep(0.2)
|
|
|
|
metrics = self.calculate_ownership_metrics(symbol, company_name, market_cap)
|
|
|
|
if metrics:
|
|
# Apply filters
|
|
if abs(metrics['percent_change']) >= min_change_percent:
|
|
if metrics['current_institution_count'] >= min_institutions:
|
|
results.append(metrics)
|
|
|
|
print(f"\nFound {len(results)} stocks meeting criteria")
|
|
|
|
# Sort results
|
|
if sort_by == 'ownership_change':
|
|
results.sort(key=lambda x: abs(x['percent_change']), reverse=True)
|
|
elif sort_by == 'institution_count_change':
|
|
results.sort(key=lambda x: abs(x['institution_count_change']), reverse=True)
|
|
elif sort_by == 'dollar_value_change':
|
|
results.sort(key=lambda x: abs(x['value_change']), reverse=True)
|
|
|
|
return results[:top]
|
|
|
|
def generate_report(self, results: List[Dict], output_file: str = None):
|
|
"""Generate markdown report from screening results"""
|
|
|
|
if not results:
|
|
print("No results to report")
|
|
return
|
|
|
|
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
report = f"""# Institutional Flow Analysis Report
|
|
**Generated:** {timestamp}
|
|
**Stocks Analyzed:** {len(results)}
|
|
|
|
## Summary
|
|
|
|
This report identifies stocks with significant institutional ownership changes based on 13F filings data.
|
|
|
|
### Key Findings
|
|
|
|
**Top Accumulators (Institutions Buying):**
|
|
"""
|
|
|
|
# Top accumulators
|
|
accumulators = [r for r in results if r['percent_change'] > 0][:10]
|
|
if accumulators:
|
|
report += "\n| Symbol | Company | Ownership Change | Institution Change | Top Holder |\n"
|
|
report += "|--------|---------|-----------------|-------------------|------------|\n"
|
|
for r in accumulators:
|
|
top_holder = r['top_holders'][0]['name'] if r['top_holders'] else 'N/A'
|
|
report += f"| {r['symbol']} | {r['company_name'][:30]} | **+{r['percent_change']}%** | +{r['institution_count_change']} | {top_holder[:30]} |\n"
|
|
else:
|
|
report += "\nNo significant accumulation detected.\n"
|
|
|
|
report += "\n**Top Distributors (Institutions Selling):**\n"
|
|
|
|
# Top distributors
|
|
distributors = [r for r in results if r['percent_change'] < 0][:10]
|
|
if distributors:
|
|
report += "\n| Symbol | Company | Ownership Change | Institution Change | Previously Top Holder |\n"
|
|
report += "|--------|---------|-----------------|-------------------|-----------------------|\n"
|
|
for r in distributors:
|
|
top_holder = r['top_holders'][0]['name'] if r['top_holders'] else 'N/A'
|
|
report += f"| {r['symbol']} | {r['company_name'][:30]} | **{r['percent_change']}%** | {r['institution_count_change']} | {top_holder[:30]} |\n"
|
|
else:
|
|
report += "\nNo significant distribution detected.\n"
|
|
|
|
report += "\n## Detailed Results\n\n"
|
|
|
|
for r in results[:20]: # Top 20 detailed
|
|
direction = "Accumulation" if r['percent_change'] > 0 else "Distribution"
|
|
report += f"""### {r['symbol']} - {r['company_name']}
|
|
|
|
**Signal:** {direction} ({r['percent_change']:+.2f}% institutional ownership change)
|
|
|
|
**Metrics:**
|
|
- Market Cap: ${r['market_cap']:,.0f}
|
|
- Current Quarter: {r['current_quarter']}
|
|
- Previous Quarter: {r['previous_quarter']}
|
|
- Institution Count Change: {r['institution_count_change']:+d} ({r['previous_institution_count']} → {r['current_institution_count']})
|
|
- Total Shares Change: {r['shares_change']:+,.0f}
|
|
- Estimated Value Change: ${r['value_change']:+,.0f}
|
|
|
|
**Top 5 Current Holders:**
|
|
"""
|
|
for i, holder in enumerate(r['top_holders'][:5], 1):
|
|
report += f"{i}. {holder['name']}: {holder['shares']:,} shares (Change: {holder['change']:+,})\n"
|
|
|
|
report += "\n---\n\n"
|
|
|
|
report += f"""
|
|
## Interpretation Guide
|
|
|
|
**Strong Accumulation (>15% increase):**
|
|
- Monitor for potential breakout
|
|
- Validate with fundamental analysis
|
|
- Consider initiating/adding to position
|
|
|
|
**Moderate Accumulation (7-15% increase):**
|
|
- Positive signal
|
|
- Combine with other analysis
|
|
- Watch for continuation
|
|
|
|
**Strong Distribution (>15% decrease):**
|
|
- Warning sign
|
|
- Re-evaluate thesis
|
|
- Consider trimming/exiting
|
|
|
|
**Moderate Distribution (7-15% decrease):**
|
|
- Early warning
|
|
- Monitor closely
|
|
- Tighten stop-loss
|
|
|
|
For detailed interpretation framework, see:
|
|
`institutional-flow-tracker/references/interpretation_framework.md`
|
|
|
|
---
|
|
|
|
**Data Source:** Financial Modeling Prep API (13F Filings)
|
|
**Note:** 13F data has ~45-day reporting lag. Use as confirming indicator, not real-time signal.
|
|
"""
|
|
|
|
# Save to file
|
|
if output_file:
|
|
output_path = output_file if output_file.endswith('.md') else f"{output_file}.md"
|
|
with open(output_path, 'w') as f:
|
|
f.write(report)
|
|
print(f"\nReport saved to: {output_path}")
|
|
else:
|
|
output_path = f"institutional_flow_screening_{datetime.now().strftime('%Y%m%d')}.md"
|
|
with open(output_path, 'w') as f:
|
|
f.write(report)
|
|
print(f"\nReport saved to: {output_path}")
|
|
|
|
return report
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description='Track institutional ownership changes across stocks',
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
# Screen top 50 stocks by institutional change (>10%)
|
|
python3 track_institutional_flow.py --top 50 --min-change-percent 10
|
|
|
|
# Focus on Technology sector
|
|
python3 track_institutional_flow.py --sector Technology --min-institutions 20
|
|
|
|
# Custom screening with output
|
|
python3 track_institutional_flow.py --min-market-cap 2000000000 --top 100 --output results.json
|
|
"""
|
|
)
|
|
|
|
parser.add_argument(
|
|
'--api-key',
|
|
type=str,
|
|
default=os.getenv('FMP_API_KEY'),
|
|
help='FMP API key (or set FMP_API_KEY environment variable)'
|
|
)
|
|
parser.add_argument(
|
|
'--top',
|
|
type=int,
|
|
default=50,
|
|
help='Number of top stocks to return (default: 50)'
|
|
)
|
|
parser.add_argument(
|
|
'--min-change-percent',
|
|
type=float,
|
|
default=10.0,
|
|
help='Minimum %% change in institutional ownership (default: 10.0)'
|
|
)
|
|
parser.add_argument(
|
|
'--min-market-cap',
|
|
type=int,
|
|
default=1000000000,
|
|
help='Minimum market cap in dollars (default: 1B)'
|
|
)
|
|
parser.add_argument(
|
|
'--sector',
|
|
type=str,
|
|
help='Filter by specific sector (e.g., Technology, Healthcare)'
|
|
)
|
|
parser.add_argument(
|
|
'--min-institutions',
|
|
type=int,
|
|
default=10,
|
|
help='Minimum number of institutional holders (default: 10)'
|
|
)
|
|
parser.add_argument(
|
|
'--sort-by',
|
|
type=str,
|
|
choices=['ownership_change', 'institution_count_change', 'dollar_value_change'],
|
|
default='ownership_change',
|
|
help='Sort results by metric (default: ownership_change)'
|
|
)
|
|
parser.add_argument(
|
|
'--output',
|
|
type=str,
|
|
help='Output file path for JSON results'
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Validate API key
|
|
if not args.api_key:
|
|
print("Error: FMP API key required")
|
|
print("Set FMP_API_KEY environment variable or pass --api-key argument")
|
|
print("Get free API key at: https://financialmodelingprep.com/developer/docs")
|
|
sys.exit(1)
|
|
|
|
# Initialize tracker
|
|
tracker = InstitutionalFlowTracker(args.api_key)
|
|
|
|
# Run screening
|
|
results = tracker.screen_stocks(
|
|
min_market_cap=args.min_market_cap,
|
|
min_change_percent=args.min_change_percent,
|
|
min_institutions=args.min_institutions,
|
|
sector=args.sector,
|
|
top=args.top,
|
|
sort_by=args.sort_by
|
|
)
|
|
|
|
# Save JSON results if requested
|
|
if args.output:
|
|
json_output = args.output if args.output.endswith('.json') else f"{args.output}.json"
|
|
with open(json_output, 'w') as f:
|
|
json.dump(results, f, indent=2)
|
|
print(f"JSON results saved to: {json_output}")
|
|
|
|
# Generate markdown report
|
|
tracker.generate_report(results)
|
|
|
|
# Print summary
|
|
if results:
|
|
print("\n" + "="*80)
|
|
print("TOP 10 INSTITUTIONAL FLOW CHANGES")
|
|
print("="*80)
|
|
print(f"{'Symbol':<8} {'Company':<30} {'Change':>10} {'Institutions':>12}")
|
|
print("-"*80)
|
|
for r in results[:10]:
|
|
print(f"{r['symbol']:<8} {r['company_name'][:30]:<30} {r['percent_change']:>9.2f}% {r['institution_count_change']:>+11d}")
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|