Papaflessas's picture
Deploy Signal Generator app
3fe0726
"""
Investment Decision Maker
Main entry point: Input ticker → Output BUY/SELL/HOLD recommendation
Uses composite scoring based on institutional methodology
"""
import pandas as pd
import numpy as np
from typing import Dict, Tuple, Optional, List
from fundamental_analysis.calculator import calculate_metrics_for_ticker
from fundamental_analysis.sector_analyzer import SectorAnalyzer
import yfinance as yf
import warnings
warnings.filterwarnings('ignore')
class InvestmentDecision:
"""Make BUY/SELL/HOLD decisions based on comprehensive analysis"""
# Scoring weights based on institutional methodology
WEIGHTS = {
'fcf_yield': 0.25, # 25% - HIGHEST priority
'roic': 0.25, # 25% - Quality indicator
'growth': 0.15, # 15% - Growth metrics
'valuation': 0.15, # 15% - Valuation ratios
'leverage': 0.10, # 10% - Financial health
'capital_allocation': 0.10 # 10% - Capital management
}
# Decision thresholds
THRESHOLDS = {
'buy': 0.35, # Score >= 0.35 → BUY
'sell': -0.10 # Score < -0.10 → SELL
}
def __init__(self, ticker: str, compare_to_sector: bool = True):
"""
Initialize decision maker
Args:
ticker: Stock ticker symbol
compare_to_sector: Whether to compare against sector peers
"""
self.ticker = ticker.upper()
self.compare_to_sector = compare_to_sector
self.metrics_df = pd.DataFrame()
self.metrics_dict = {}
self.sector_comparison = pd.DataFrame()
self.sector_percentiles = {}
self.scores = {}
self.final_score = 0.0
self.recommendation = "HOLD"
self.confidence = 0.0
self.reasoning = []
def analyze(self) -> Dict:
"""
Perform complete analysis and generate recommendation
Returns:
Dictionary with recommendation and detailed analysis
"""
print(f"\n{'='*80}")
print(f"INVESTMENT ANALYSIS: {self.ticker}")
print(f"{'='*80}\n")
# Step 1: Calculate all metrics
print("Step 1: Calculating financial metrics...")
self.metrics_df, summary = calculate_metrics_for_ticker(self.ticker)
if self.metrics_df.empty:
return self._error_result("Failed to fetch data")
# Convert metrics to dictionary for easier access
self._build_metrics_dict()
# Step 2: Get sector comparison
if self.compare_to_sector:
print("\nStep 2: Comparing to sector peers...")
self._compare_to_sector()
else:
print("\nStep 2: Skipping sector comparison")
# Step 3: Score each category
print("\nStep 3: Scoring investment criteria...")
self._score_all_categories()
# Step 4: Calculate final score and recommendation
print("\nStep 4: Generating recommendation...")
self._calculate_final_score()
self._determine_recommendation()
# Step 5: Build reasoning
self._build_reasoning()
# Return complete analysis
return self._build_result()
def _build_metrics_dict(self):
"""Convert metrics DataFrame to dictionary for easier access"""
for _, row in self.metrics_df.iterrows():
if row['Status'] == 'Available':
metric_name = row['Metric'].replace(' ', '_').replace('/', '_').replace('(', '').replace(')', '').replace('%', 'pct')
self.metrics_dict[metric_name] = row['Value']
def _get_metric(self, metric_name: str) -> Optional[float]:
"""Safely get metric value"""
row = self.metrics_df[self.metrics_df['Metric'] == metric_name]
if not row.empty and row.iloc[0]['Status'] == 'Available':
return row.iloc[0]['Value']
return None
def _compare_to_sector(self):
"""Compare stock to sector peers"""
try:
# Get company info to determine sector
stock = yf.Ticker(self.ticker)
info = stock.info
sector = info.get('sector', 'Unknown')
if sector == 'Unknown':
print(" ⚠ Could not determine sector, skipping comparison")
return
print(f" Sector: {sector}")
# Map yfinance sectors to our predefined sectors
sector_mapping = {
'Technology': 'Technology',
'Financial Services': 'Financial',
'Healthcare': 'Healthcare',
'Consumer Cyclical': 'Consumer',
'Consumer Defensive': 'Consumer',
'Industrials': 'Industrial',
'Energy': 'Energy',
'Basic Materials': 'Materials',
'Real Estate': 'Real Estate',
'Communication Services': 'Communication'
}
mapped_sector = sector_mapping.get(sector)
if not mapped_sector:
print(f" ⚠ Sector '{sector}' not in predefined list")
return
# Analyze sector with a subset of stocks (to avoid long wait)
analyzer = SectorAnalyzer(mapped_sector)
tickers = analyzer.get_sector_tickers()
# Limit to 10 stocks for faster analysis
if len(tickers) > 10:
tickers = tickers[:10]
print(f" Analyzing {len(tickers)} peer stocks (limited sample)...")
comparison_df = analyzer.calculate_sector_metrics(tickers)
if not comparison_df.empty and self.ticker in comparison_df.index:
self.sector_comparison = analyzer.compare_stock_to_peers(self.ticker, comparison_df)
self.sector_percentiles = comparison_df.rank(pct=True).loc[self.ticker].to_dict()
print(f" ✓ Sector comparison complete")
else:
print(f" ⚠ Could not compare to sector")
except Exception as e:
print(f" ⚠ Sector comparison error: {str(e)}")
def _score_all_categories(self):
"""Score each investment category"""
self.scores['fcf_yield'] = self._score_fcf_yield()
self.scores['roic'] = self._score_roic()
self.scores['growth'] = self._score_growth()
self.scores['valuation'] = self._score_valuation()
self.scores['leverage'] = self._score_leverage()
self.scores['capital_allocation'] = self._score_capital_allocation()
# Print scores
for category, score in self.scores.items():
print(f" {category.replace('_', ' ').title()}: {score:+.2f}")
def _score_fcf_yield(self) -> float:
"""
Score FCF Yield (HIGHEST PRIORITY - 25% weight)
Threshold: >6% = BUY, 4-6% = HOLD, <3% = SELL
"""
fcf_yield = self._get_metric('FCF Yield (Enterprise) %')
if fcf_yield is None:
return 0.0
# Scoring rules
if fcf_yield >= 6.0:
score = 1.0 # Strong buy signal
elif fcf_yield >= 4.0:
score = 0.5 # Hold
elif fcf_yield >= 3.0:
score = 0.0 # Neutral
else:
score = -1.0 # Sell signal
# Adjust based on sector percentile if available
if 'FCF_Yield_%' in self.sector_percentiles:
percentile = self.sector_percentiles['FCF_Yield_%']
if percentile > 0.75:
score += 0.3 # Top quartile bonus
elif percentile < 0.25:
score -= 0.3 # Bottom quartile penalty
return np.clip(score, -1.0, 1.0)
def _score_roic(self) -> float:
"""
Score ROIC (VERY HIGH PRIORITY - 25% weight)
Threshold: >15% = excellent, >10% = good, <10% = concern
"""
roic = self._get_metric('Return on Invested Capital (ROIC) %')
if roic is None:
return 0.0
# Scoring rules
if roic >= 20.0:
score = 1.0 # Exceptional
elif roic >= 15.0:
score = 0.7 # Excellent
elif roic >= 10.0:
score = 0.3 # Good
elif roic >= 5.0:
score = -0.3 # Mediocre
else:
score = -1.0 # Poor
# Adjust based on sector percentile
if 'ROIC_%' in self.sector_percentiles:
percentile = self.sector_percentiles['ROIC_%']
if percentile > 0.75:
score += 0.2
elif percentile < 0.25:
score -= 0.2
return np.clip(score, -1.0, 1.0)
def _score_growth(self) -> float:
"""
Score Growth Metrics (15% weight)
Revenue growth, EPS growth
"""
rev_growth = self._get_metric('Revenue Growth (YoY) %')
eps_growth = self._get_metric('EPS Growth (YoY) %')
if rev_growth is None and eps_growth is None:
return 0.0
scores = []
# Revenue growth scoring
if rev_growth is not None:
if rev_growth >= 20.0:
scores.append(1.0)
elif rev_growth >= 10.0:
scores.append(0.5)
elif rev_growth >= 5.0:
scores.append(0.2)
elif rev_growth >= 0.0:
scores.append(-0.2)
else:
scores.append(-1.0) # Declining revenue
# EPS growth scoring
if eps_growth is not None:
if eps_growth >= 20.0:
scores.append(1.0)
elif eps_growth >= 10.0:
scores.append(0.5)
elif eps_growth >= 5.0:
scores.append(0.2)
elif eps_growth >= 0.0:
scores.append(-0.2)
else:
scores.append(-1.0)
return np.clip(np.mean(scores) if scores else 0.0, -1.0, 1.0)
def _score_valuation(self) -> float:
"""
Score Valuation Metrics (15% weight)
P/E, PEG, EV/EBITDA relative to sector
"""
pe_ratio = self._get_metric('P/E Ratio (TTM)')
peg_ratio = self._get_metric('PEG Ratio')
ev_ebitda = self._get_metric('EV/EBITDA')
scores = []
# PEG ratio scoring (most important valuation metric)
if peg_ratio is not None:
if peg_ratio < 0.8:
scores.append(1.0) # Undervalued
elif peg_ratio < 1.2:
scores.append(0.3) # Fair value
elif peg_ratio < 1.5:
scores.append(-0.3) # Slightly expensive
else:
scores.append(-1.0) # Overvalued
# P/E relative to sector
if 'PE_Ratio' in self.sector_percentiles:
percentile = self.sector_percentiles['PE_Ratio']
# Lower P/E is better (reverse percentile)
if percentile < 0.33:
scores.append(0.7) # Cheap relative to sector
elif percentile < 0.67:
scores.append(0.0) # Fair
else:
scores.append(-0.7) # Expensive
# EV/EBITDA relative to sector
if 'EV_EBITDA' in self.sector_percentiles:
percentile = self.sector_percentiles['EV_EBITDA']
# Lower is better
if percentile < 0.33:
scores.append(0.5)
elif percentile > 0.67:
scores.append(-0.5)
return np.clip(np.mean(scores) if scores else 0.0, -1.0, 1.0)
def _score_leverage(self) -> float:
"""
Score Leverage/Financial Health (10% weight)
Net Debt/EBITDA, Interest Coverage
"""
net_debt_ebitda = self._get_metric('Net Debt / EBITDA')
current_ratio = self._get_metric('Current Ratio')
cash_conversion = self._get_metric('Cash Conversion Ratio')
scores = []
# Net Debt/EBITDA scoring
if net_debt_ebitda is not None:
if net_debt_ebitda < 1.0:
scores.append(1.0) # Very low leverage
elif net_debt_ebitda < 2.0:
scores.append(0.5) # Moderate
elif net_debt_ebitda < 3.0:
scores.append(0.0) # Acceptable
elif net_debt_ebitda < 4.0:
scores.append(-0.5) # High
else:
scores.append(-1.0) # Very high risk
# Current ratio
if current_ratio is not None:
if current_ratio >= 2.0:
scores.append(0.5)
elif current_ratio >= 1.5:
scores.append(0.2)
elif current_ratio >= 1.0:
scores.append(-0.2)
else:
scores.append(-0.5)
# Cash conversion (quality of earnings)
if cash_conversion is not None:
if cash_conversion >= 1.2:
scores.append(0.5)
elif cash_conversion >= 1.0:
scores.append(0.2)
elif cash_conversion >= 0.8:
scores.append(-0.2)
else:
scores.append(-0.5) # Red flag
return np.clip(np.mean(scores) if scores else 0.0, -1.0, 1.0)
def _score_capital_allocation(self) -> float:
"""
Score Capital Allocation (10% weight)
Dividends, buybacks, total payout ratio
"""
payout_ratio = self._get_metric('Payout Ratio %')
total_payout = self._get_metric('Total Payout Ratio %')
roe = self._get_metric('Return on Equity (ROE) %')
scores = []
# Payout ratio - should be sustainable
if payout_ratio is not None:
if payout_ratio < 40.0:
scores.append(0.5) # Low, room to grow
elif payout_ratio < 60.0:
scores.append(0.3) # Sustainable
elif payout_ratio < 80.0:
scores.append(-0.2) # High
else:
scores.append(-0.5) # Potentially unsustainable
# Total payout (dividends + buybacks)
if total_payout is not None and roe is not None:
# Good capital allocation returns cash to shareholders while maintaining high ROE
if roe > 15.0 and total_payout > 50.0:
scores.append(0.5) # Strong returns + returning cash
elif roe > 15.0:
scores.append(0.3) # Strong returns, could return more
elif total_payout > 50.0:
scores.append(-0.3) # Returning cash but weak returns
return np.clip(np.mean(scores) if scores else 0.0, -1.0, 1.0)
def _calculate_final_score(self):
"""Calculate weighted final score"""
self.final_score = sum(
self.scores.get(category, 0.0) * weight
for category, weight in self.WEIGHTS.items()
)
# Calculate confidence based on data availability and sector comparison
data_completeness = len([s for s in self.scores.values() if s != 0.0]) / len(self.scores)
sector_bonus = 0.15 if not self.sector_comparison.empty else 0.0
self.confidence = min(data_completeness + sector_bonus, 1.0)
def _determine_recommendation(self):
"""Determine BUY/SELL/HOLD based on final score"""
if self.final_score >= self.THRESHOLDS['buy']:
self.recommendation = "BUY"
elif self.final_score < self.THRESHOLDS['sell']:
self.recommendation = "SELL"
else:
self.recommendation = "HOLD"
def _build_reasoning(self):
"""Build human-readable reasoning for the recommendation"""
self.reasoning = []
# Overall assessment
if self.final_score >= 0.5:
self.reasoning.append("✓ Strong overall fundamentals")
elif self.final_score >= 0.2:
self.reasoning.append("✓ Positive fundamentals")
elif self.final_score >= -0.2:
self.reasoning.append("• Mixed fundamentals")
else:
self.reasoning.append("✗ Weak fundamentals")
# FCF Yield
fcf_yield = self._get_metric('FCF Yield (Enterprise) %')
if fcf_yield:
if fcf_yield >= 6.0:
self.reasoning.append(f"✓ Excellent FCF yield: {fcf_yield:.2f}%")
elif fcf_yield < 3.0:
self.reasoning.append(f"✗ Low FCF yield: {fcf_yield:.2f}%")
# ROIC
roic = self._get_metric('Return on Invested Capital (ROIC) %')
if roic:
if roic >= 15.0:
self.reasoning.append(f"✓ Strong ROIC: {roic:.2f}%")
elif roic < 10.0:
self.reasoning.append(f"✗ Weak ROIC: {roic:.2f}%")
# Growth
rev_growth = self._get_metric('Revenue Growth (YoY) %')
if rev_growth:
if rev_growth >= 15.0:
self.reasoning.append(f"✓ Strong revenue growth: {rev_growth:.2f}%")
elif rev_growth < 0:
self.reasoning.append(f"✗ Declining revenue: {rev_growth:.2f}%")
# Valuation
peg_ratio = self._get_metric('PEG Ratio')
if peg_ratio:
if peg_ratio < 0.8:
self.reasoning.append(f"✓ Undervalued (PEG: {peg_ratio:.2f})")
elif peg_ratio > 1.5:
self.reasoning.append(f"✗ Overvalued (PEG: {peg_ratio:.2f})")
# Leverage
net_debt_ebitda = self._get_metric('Net Debt / EBITDA')
if net_debt_ebitda is not None:
if net_debt_ebitda < 1.0:
self.reasoning.append(f"✓ Low leverage: {net_debt_ebitda:.2f}x")
elif net_debt_ebitda > 3.0:
self.reasoning.append(f"✗ High leverage: {net_debt_ebitda:.2f}x")
# Sector comparison
if 'ROIC_%' in self.sector_percentiles:
roic_pct = self.sector_percentiles['ROIC_%']
if roic_pct > 0.75:
self.reasoning.append(f"✓ Top quartile ROIC vs peers (P{int(roic_pct*100)})")
elif roic_pct < 0.25:
self.reasoning.append(f"✗ Bottom quartile ROIC vs peers (P{int(roic_pct*100)})")
def _build_result(self) -> Dict:
"""Build final result dictionary"""
return {
'ticker': self.ticker,
'recommendation': self.recommendation,
'final_score': self.final_score,
'confidence': self.confidence,
'category_scores': self.scores,
'reasoning': self.reasoning,
'key_metrics': {
'FCF_Yield_%': self._get_metric('FCF Yield (Enterprise) %'),
'ROIC_%': self._get_metric('Return on Invested Capital (ROIC) %'),
'ROE_%': self._get_metric('Return on Equity (ROE) %'),
'Revenue_Growth_%': self._get_metric('Revenue Growth (YoY) %'),
'PEG_Ratio': self._get_metric('PEG Ratio'),
'Net_Debt_EBITDA': self._get_metric('Net Debt / EBITDA'),
},
'sector_percentiles': self.sector_percentiles if self.sector_percentiles else None
}
def _error_result(self, error_message: str) -> Dict:
"""Return error result"""
return {
'ticker': self.ticker,
'recommendation': 'ERROR',
'error': error_message,
'final_score': 0.0,
'confidence': 0.0
}
def print_analysis(self, result: Dict):
"""Print formatted analysis report"""
print(f"\n{'='*80}")
print(f"INVESTMENT RECOMMENDATION: {result['ticker']}")
print(f"{'='*80}")
if result['recommendation'] == 'ERROR':
print(f"\n✗ ERROR: {result.get('error', 'Unknown error')}")
return
# Recommendation with color coding
rec = result['recommendation']
if rec == 'BUY':
print(f"\n🟢 RECOMMENDATION: {rec} (Score: {result['final_score']:+.2f})")
elif rec == 'SELL':
print(f"\n🔴 RECOMMENDATION: {rec} (Score: {result['final_score']:+.2f})")
else:
print(f"\n🟡 RECOMMENDATION: {rec} (Score: {result['final_score']:+.2f})")
print(f"Confidence: {result['confidence']:.0%}")
# Category scores
print(f"\n{'-'*80}")
print("CATEGORY SCORES (weighted)")
print(f"{'-'*80}")
for category, score in result['category_scores'].items():
weight = self.WEIGHTS[category]
weighted_score = score * weight
bar_length = int(abs(score) * 20)
bar = '█' * bar_length
print(f"{category.replace('_', ' ').title():25} {score:+.2f} ({weight:.0%}) → {weighted_score:+.3f} {bar}")
# Key metrics
print(f"\n{'-'*80}")
print("KEY METRICS")
print(f"{'-'*80}")
for metric, value in result['key_metrics'].items():
if value is not None:
print(f"{metric:30} {value:>12.2f}")
# Reasoning
print(f"\n{'-'*80}")
print("INVESTMENT RATIONALE")
print(f"{'-'*80}")
for reason in result['reasoning']:
print(f" {reason}")
print(f"\n{'='*80}\n")
def evaluate_stock(ticker: str, compare_to_sector: bool = True) -> Dict:
"""
Main function to evaluate a stock and get recommendation
Args:
ticker: Stock ticker symbol
compare_to_sector: Whether to compare to sector peers
Returns:
Dictionary with recommendation and analysis
"""
decision = InvestmentDecision(ticker, compare_to_sector)
result = decision.analyze()
decision.print_analysis(result)
return result
if __name__ == "__main__":
# Test with sample tickers
test_tickers = ['AAPL', 'GOOGL', 'MSFT']
print("INVESTMENT DECISION MAKER TEST")
print("=" * 80)
results = []
for ticker in test_tickers:
result = evaluate_stock(ticker, compare_to_sector=False) # Disable sector for speed
results.append(result)
# Summary comparison
print("\n" + "=" * 80)
print("SUMMARY COMPARISON")
print("=" * 80)
summary_df = pd.DataFrame([{
'Ticker': r['ticker'],
'Recommendation': r['recommendation'],
'Score': r['final_score'],
'Confidence': r['confidence'],
'FCF Yield %': r['key_metrics']['FCF_Yield_%'],
'ROIC %': r['key_metrics']['ROIC_%'],
'PEG': r['key_metrics']['PEG_Ratio']
} for r in results])
print(summary_df.to_string(index=False))
# Save results
summary_df.to_csv('investment_recommendations.csv', index=False)
print(f"\n✓ Results saved to investment_recommendations.csv")