#!/usr/bin/env python3
"""
Comparable Company Analysis Module — Phase 141
Auto-generate comps tables with EV/EBITDA, P/E, margins for any sector
Quarterly valuation multiples and financial metrics for peer analysis
Data Sources:
- Yahoo Finance (yfinance): Fundamental data, valuation multiples
- SEC XBRL (fallback): Financial statements for more accurate metrics
Coverage:
- Valuation Multiples: P/E, EV/EBITDA, P/S, P/B, PEG
- Profitability Metrics: Gross Margin, Operating Margin, Net Margin, ROE, ROA
- Growth Metrics: Revenue Growth, Earnings Growth, FCF Growth
- Efficiency Metrics: Asset Turnover, Inventory Turnover
- Leverage Metrics: Debt/Equity, Current Ratio, Quick Ratio
Refresh: Quarterly with earnings releases
Author: QUANTCLAW DATA Build Agent
Phase: 141
"""
import sys
import json
from typing import Dict, List, Optional
from datetime import datetime
import yfinance as yf
# Industry/Sector Peer Groups (curated lists for common sectors)
PEER_GROUPS = {
'MEGACAP_TECH': ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'META', 'NVDA', 'TSLA'],
'CLOUD_SAAS': ['CRM', 'NOW', 'SNOW', 'DDOG', 'NET', 'ZS', 'OKTA'],
'SEMICONDUCTORS': ['NVDA', 'AMD', 'INTC', 'TSM', 'AVGO', 'QCOM', 'MU'],
'BANKS': ['JPM', 'BAC', 'WFC', 'C', 'GS', 'MS', 'USB'],
'CONSUMER_STAPLES': ['PG', 'KO', 'PEP', 'WMT', 'COST', 'CL', 'KMB'],
'PHARMA': ['JNJ', 'PFE', 'MRK', 'ABBV', 'LLY', 'BMY', 'GILD'],
'AUTOMOTIVE': ['TSLA', 'GM', 'F', 'RIVN', 'LCID', 'TM', 'HMC'],
'AEROSPACE': ['BA', 'LMT', 'RTX', 'GD', 'NOC', 'TXT', 'HWM'],
'RETAIL': ['AMZN', 'WMT', 'TGT', 'HD', 'LOW', 'COST', 'DG'],
'FINTECH': ['SQ', 'PYPL', 'V', 'MA', 'ADYEN.AS', 'FIS', 'FISV'],
'ENERGY': ['XOM', 'CVX', 'COP', 'SLB', 'EOG', 'MPC', 'PSX'],
'UTILITIES': ['NEE', 'DUK', 'SO', 'D', 'AEP', 'EXC', 'SRE']
}
def get_peer_group(ticker: str, use_sector: bool = True) -> List[str]:
"""
Get peer companies for a given ticker
Args:
ticker: Stock ticker symbol
use_sector: If True, find peers in same sector; else use industry
Returns:
List of peer ticker symbols
"""
try:
ticker_upper = ticker.upper()
# Check if ticker is in a predefined peer group
for group_name, tickers in PEER_GROUPS.items():
if ticker_upper in tickers:
# Return peers (excluding the input ticker)
return [t for t in tickers if t != ticker_upper]
# Otherwise, use yfinance to find sector/industry peers
stock = yf.Ticker(ticker)
info = stock.info
if not info:
return []
sector = info.get('sector', '')
industry = info.get('industry', '')
# For now, return empty list if not in predefined groups
# In production, this would query a database of sector constituents
print(f"Ticker: {ticker_upper}", file=sys.stderr)
print(f"Sector: {sector}", file=sys.stderr)
print(f"Industry: {industry}", file=sys.stderr)
print(f"Note: Add to PEER_GROUPS for automatic peer matching", file=sys.stderr)
return []
except Exception as e:
print(f"Error finding peer group: {e}", file=sys.stderr)
return []
def get_company_metrics(ticker: str) -> Optional[Dict]:
"""
Get comprehensive financial metrics for a single company
Args:
ticker: Stock ticker symbol
Returns:
Dictionary of financial metrics
"""
try:
stock = yf.Ticker(ticker)
info = stock.info
if not info or 'longName' not in info:
return None
# Extract valuation multiples
pe_ratio = info.get('trailingPE') or info.get('forwardPE')
peg_ratio = info.get('pegRatio')
# Calculate EV/EBITDA if components available
enterprise_value = info.get('enterpriseValue')
ebitda = info.get('ebitda')
ev_ebitda = None
if enterprise_value and ebitda and ebitda > 0:
ev_ebitda = enterprise_value / ebitda
# Price multiples
price_to_sales = info.get('priceToSalesTrailing12Months')
price_to_book = info.get('priceToBook')
# Profitability margins
gross_margin = info.get('grossMargins')
operating_margin = info.get('operatingMargins')
profit_margin = info.get('profitMargins')
# Returns
roe = info.get('returnOnEquity')
roa = info.get('returnOnAssets')
# Growth metrics
revenue_growth = info.get('revenueGrowth')
earnings_growth = info.get('earningsGrowth')
# Leverage
debt_to_equity = info.get('debtToEquity')
current_ratio = info.get('currentRatio')
quick_ratio = info.get('quickRatio')
# Market data
market_cap = info.get('marketCap')
enterprise_value = info.get('enterpriseValue')
# Fundamental data
revenue = info.get('totalRevenue')
net_income = info.get('netIncomeToCommon')
total_cash = info.get('totalCash')
total_debt = info.get('totalDebt')
free_cashflow = info.get('freeCashflow')
result = {
'ticker': ticker.upper(),
'name': info.get('longName', ''),
'sector': info.get('sector', ''),
'industry': info.get('industry', ''),
'market_cap': market_cap,
'enterprise_value': enterprise_value,
# Valuation Multiples
'valuation': {
'pe_ratio': pe_ratio,
'forward_pe': info.get('forwardPE'),
'trailing_pe': info.get('trailingPE'),
'peg_ratio': peg_ratio,
'ev_ebitda': ev_ebitda,
'price_to_sales': price_to_sales,
'price_to_book': price_to_book,
'ev_to_revenue': info.get('enterpriseToRevenue'),
},
# Profitability
'profitability': {
'gross_margin': gross_margin,
'operating_margin': operating_margin,
'profit_margin': profit_margin,
'roe': roe,
'roa': roa,
},
# Growth
'growth': {
'revenue_growth': revenue_growth,
'earnings_growth': earnings_growth,
'revenue_growth_quarterly': info.get('revenueGrowthQuarterly'),
'earnings_quarterly_growth': info.get('earningsQuarterlyGrowth'),
},
# Leverage & Liquidity
'leverage': {
'debt_to_equity': debt_to_equity,
'current_ratio': current_ratio,
'quick_ratio': quick_ratio,
'total_debt': total_debt,
'total_cash': total_cash,
},
# Fundamentals
'fundamentals': {
'revenue': revenue,
'net_income': net_income,
'ebitda': ebitda,
'free_cashflow': free_cashflow,
'shares_outstanding': info.get('sharesOutstanding'),
},
# Metadata
'metadata': {
'last_updated': datetime.now().isoformat(),
'currency': info.get('currency', 'USD'),
'exchange': info.get('exchange', ''),
}
}
return result
except Exception as e:
print(f"Error fetching metrics for {ticker}: {e}", file=sys.stderr)
return None
def generate_comps_table(tickers: List[str], metrics: Optional[List[str]] = None) -> Dict:
"""
Generate a comparable company analysis table
Args:
tickers: List of ticker symbols to compare
metrics: Specific metrics to include (None = all)
Returns:
Dictionary with comparison table and statistics
"""
result = {
'tickers': tickers,
'companies': [],
'summary_stats': {},
'generated_at': datetime.now().isoformat()
}
# Fetch metrics for each company
for ticker in tickers:
company_data = get_company_metrics(ticker)
if company_data:
result['companies'].append(company_data)
if not result['companies']:
result['error'] = "No company data available"
return result
# Calculate summary statistics for key metrics
def safe_average(values):
"""Calculate average, filtering out None values"""
valid = [v for v in values if v is not None]
return sum(valid) / len(valid) if valid else None
def safe_median(values):
"""Calculate median, filtering out None values"""
valid = sorted([v for v in values if v is not None])
if not valid:
return None
n = len(valid)
if n % 2 == 0:
return (valid[n//2 - 1] + valid[n//2]) / 2
else:
return valid[n//2]
# Collect values for each metric across all companies
metric_collections = {
'pe_ratio': [c['valuation']['pe_ratio'] for c in result['companies']],
'ev_ebitda': [c['valuation']['ev_ebitda'] for c in result['companies']],
'price_to_sales': [c['valuation']['price_to_sales'] for c in result['companies']],
'price_to_book': [c['valuation']['price_to_book'] for c in result['companies']],
'gross_margin': [c['profitability']['gross_margin'] for c in result['companies']],
'operating_margin': [c['profitability']['operating_margin'] for c in result['companies']],
'profit_margin': [c['profitability']['profit_margin'] for c in result['companies']],
'roe': [c['profitability']['roe'] for c in result['companies']],
'revenue_growth': [c['growth']['revenue_growth'] for c in result['companies']],
'debt_to_equity': [c['leverage']['debt_to_equity'] for c in result['companies']],
}
# Calculate stats for each metric
for metric_name, values in metric_collections.items():
result['summary_stats'][metric_name] = {
'mean': safe_average(values),
'median': safe_median(values),
'min': min([v for v in values if v is not None], default=None),
'max': max([v for v in values if v is not None], default=None),
}
return result
def compare_to_peers(ticker: str, peer_group: Optional[List[str]] = None) -> Dict:
"""
Compare a company to its peers
Args:
ticker: Target company ticker
peer_group: List of peer tickers (None = auto-detect)
Returns:
Comparison with peer group statistics
"""
ticker_upper = ticker.upper()
# Auto-detect peer group if not provided
if peer_group is None:
peer_group = get_peer_group(ticker_upper)
if not peer_group:
return {
'error': f"No peer group found for {ticker_upper}. "
"Provide explicit peer_group list or add to PEER_GROUPS."
}
# Include target company in comparison
all_tickers = [ticker_upper] + peer_group
# Generate comps table
comps = generate_comps_table(all_tickers)
if 'error' in comps:
return comps
# Find target company in results
target_company = None
for company in comps['companies']:
if company['ticker'] == ticker_upper:
target_company = company
break
if not target_company:
return {'error': f"Could not fetch data for {ticker_upper}"}
# Calculate relative positioning vs peers
result = {
'target': target_company,
'peer_group': peer_group,
'comps_table': comps,
'relative_analysis': {}
}
# Compare target metrics to peer averages
def compare_metric(value, peer_stat):
"""Compare value to peer statistics"""
if value is None or peer_stat is None:
return None
peer_mean = peer_stat.get('mean')
peer_median = peer_stat.get('median')
if peer_mean is None or peer_median is None:
return None
return {
'value': value,
'peer_mean': peer_mean,
'peer_median': peer_median,
'vs_mean_pct': ((value - peer_mean) / abs(peer_mean) * 100) if peer_mean != 0 else None,
'vs_median_pct': ((value - peer_median) / abs(peer_median) * 100) if peer_median != 0 else None,
'percentile_rank': None # Could calculate with more data
}
# Analyze key metrics
result['relative_analysis']['valuation'] = {
'pe_ratio': compare_metric(
target_company['valuation']['pe_ratio'],
comps['summary_stats']['pe_ratio']
),
'ev_ebitda': compare_metric(
target_company['valuation']['ev_ebitda'],
comps['summary_stats']['ev_ebitda']
),
'price_to_sales': compare_metric(
target_company['valuation']['price_to_sales'],
comps['summary_stats']['price_to_sales']
),
}
result['relative_analysis']['profitability'] = {
'gross_margin': compare_metric(
target_company['profitability']['gross_margin'],
comps['summary_stats']['gross_margin']
),
'operating_margin': compare_metric(
target_company['profitability']['operating_margin'],
comps['summary_stats']['operating_margin']
),
'roe': compare_metric(
target_company['profitability']['roe'],
comps['summary_stats']['roe']
),
}
result['relative_analysis']['growth'] = {
'revenue_growth': compare_metric(
target_company['growth']['revenue_growth'],
comps['summary_stats']['revenue_growth']
),
}
return result
def sector_analysis(sector_name: str, min_market_cap: Optional[float] = None) -> Dict:
"""
Analyze all companies in a sector
Args:
sector_name: Sector name (e.g., 'Technology', 'Healthcare')
min_market_cap: Minimum market cap filter (in USD)
Returns:
Sector-wide analysis with statistics
"""
# For now, use predefined peer groups
# In production, would query a database of all sector constituents
sector_mapping = {
'technology': 'MEGACAP_TECH',
'tech': 'MEGACAP_TECH',
'semiconductors': 'SEMICONDUCTORS',
'cloud': 'CLOUD_SAAS',
'saas': 'CLOUD_SAAS',
'financials': 'BANKS',
'banks': 'BANKS',
'pharma': 'PHARMA',
'healthcare': 'PHARMA',
'automotive': 'AUTOMOTIVE',
'auto': 'AUTOMOTIVE',
'retail': 'RETAIL',
'energy': 'ENERGY',
}
sector_key = sector_name.lower()
if sector_key not in sector_mapping:
return {
'error': f"Sector '{sector_name}' not found. Available: {list(sector_mapping.keys())}"
}
peer_group_name = sector_mapping[sector_key]
tickers = PEER_GROUPS.get(peer_group_name, [])
if not tickers:
return {'error': f"No tickers found for sector {sector_name}"}
# Generate comps table
comps = generate_comps_table(tickers)
if 'error' in comps:
return comps
# Apply market cap filter if specified
if min_market_cap:
comps['companies'] = [
c for c in comps['companies']
if c['market_cap'] and c['market_cap'] >= min_market_cap
]
result = {
'sector': sector_name,
'peer_group': peer_group_name,
'company_count': len(comps['companies']),
'min_market_cap_filter': min_market_cap,
'analysis': comps
}
return result
def main():
"""CLI Interface"""
import argparse
parser = argparse.ArgumentParser(description='Comparable Company Analysis')
subparsers = parser.add_subparsers(dest='command', help='Available commands')
# comp-metrics command
metrics_parser = subparsers.add_parser('comp-metrics',
help='Get metrics for a single company')
metrics_parser.add_argument('ticker', help='Stock ticker symbol')
# comps-table command
comps_parser = subparsers.add_parser('comps-table',
help='Generate comps table')
comps_parser.add_argument('tickers', nargs='+', help='Ticker symbols')
# comp-compare command
compare_parser = subparsers.add_parser('comp-compare',
help='Compare company to peers')
compare_parser.add_argument('ticker', help='Target ticker symbol')
compare_parser.add_argument('--peers', nargs='+',
help='Peer ticker symbols (auto-detect if omitted)')
# comp-sector command
sector_parser = subparsers.add_parser('comp-sector',
help='Analyze sector')
sector_parser.add_argument('sector', help='Sector name')
sector_parser.add_argument('--min-mcap', type=float,
help='Minimum market cap (USD)')
# peer-groups command
groups_parser = subparsers.add_parser('peer-groups',
help='List available peer groups')
args = parser.parse_args()
if args.command == 'comp-metrics':
result = get_company_metrics(args.ticker)
if result:
print(json.dumps(result, indent=2))
else:
print(json.dumps({'error': f'No data for {args.ticker}'}, indent=2))
elif args.command == 'comps-table':
result = generate_comps_table(args.tickers)
print(json.dumps(result, indent=2))
elif args.command == 'comp-compare':
result = compare_to_peers(args.ticker, args.peers)
print(json.dumps(result, indent=2))
elif args.command == 'comp-sector':
result = sector_analysis(args.sector, args.min_mcap)
print(json.dumps(result, indent=2))
elif args.command == 'peer-groups':
groups = {name: tickers for name, tickers in PEER_GROUPS.items()}
print(json.dumps(groups, indent=2))
else:
parser.print_help()
if __name__ == '__main__':
main()