Spaces:
Running
Running
| """ | |
| 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") | |