""" Warren Buffett Style Stock Analyzer Deterministic fundamental analysis without LLM dependency """ from typing import Dict, Optional from datetime import datetime from loguru import logger class WarrenAnalyzer: """ Analyzes stocks using Warren Buffett-style value investing principles. Uses deterministic formulas instead of LLM for fast, reliable, and cost-free analysis. """ # Financial tickers (special handling: no D/E penalties, P/B focus) FINANCIALS_TICKERS = [ 'ISP.MI', 'UCG.MI', 'BAMI.MI', 'BPE.MI', 'BMPS.MI', 'FBK.MI', 'MB.MI', 'PST.MI', 'UNI.MI', 'G.MI', ] # Utilities/Energy tickers (special handling: cashflow/debt dynamics) UTILITY_TICKERS = ['A2A.MI', 'ENEL.MI', 'IG.MI', 'SRG.MI', 'TRN.MI', 'ERG.MI'] # Automotive / Industrial tickers (cashflow can be lumpy year to year) AUTOMOTIVE_TICKERS = ['RACE.MI', 'STLAM.MI', 'IVG.MI', 'PIRC.MI'] INDUSTRIAL_TICKERS = ['PRY.MI', 'TEN.MI', 'IP.MI', 'BZU.MI', 'LDO.MI'] # Combined list for special treatment (D/E and advanced metrics tolerance) SPECIAL_TREATMENT_TICKERS = FINANCIALS_TICKERS + UTILITY_TICKERS # Hard fail thresholds HARD_FAIL_FCF_YIELD_THRESHOLD = -0.20 # -20% HARD_FAIL_NET_DEBT_EBITDA_THRESHOLD = 10.0 # Valuation targets (parametrized for easier tuning) TARGET_FCF_YIELD = 0.06 # 6% FCF yield TARGET_EV_EBITDA = 10.0 # Standard EV/EBITDA TARGET_EV_EBITDA_LUXURY = 20.0 # Premium EV/EBITDA for luxury/brand moat # Special ratings/scores SCORE_CRITICAL_DANGER = 5 SCORE_DATA_INSUFFICIENT = 10 RATING_CRITICAL_DANGER = "CRITICAL DANGER" RATING_DATA_INSUFFICIENT = "DATA INSUFFICIENT" # Scoring weights (total 100 points) VALUATION_WEIGHT = 30 # Price metrics QUALITY_WEIGHT = 40 # Business quality GROWTH_WEIGHT = 30 # Growth metrics # Recommendation thresholds BUY_SCORE = 80 STRONG_BUY_MARGIN = 20.0 # Minimum 20% margin of safety for STRONG BUY BUY_MARGIN = 15.0 # Minimum 15% margin of safety for BUY HOLD_SCORE = 60 def __init__(self): """Initialize the Warren Analyzer""" pass def analyze(self, stock_data: Dict) -> Dict: """ Analyze a stock and return Warren Buffett-style recommendation Args: stock_data: Dictionary containing: - ticker: Stock symbol - name: Company name - sector: Industry sector - price: Current price - pe_ratio: P/E ratio - pb_ratio: P/B ratio - roe: Return on Equity (as decimal, e.g., 0.15 = 15%) - debt_to_equity: Debt to Equity ratio - dividend_yield: Dividend yield (as decimal) - revenue_growth: Revenue growth rate (as decimal) - earnings_growth: Earnings growth rate (as decimal) - market_cap: Market capitalization Returns: Dictionary with: - ticker: Stock symbol - name: Company name - score: Quality score (0-100) - valutazione: BUY/HOLD/AVOID/CRITICAL DANGER/DATA INSUFFICIENT - fair_value: Estimated fair value per share - current_price: Current market price - margin_of_safety: % discount/premium - ragionamento: Explanation text """ # Early hard-fail checks (v2 metrics) fcf = stock_data.get('free_cashflow') market_cap = stock_data.get('market_cap') ev = stock_data.get('enterprise_value') ebitda = stock_data.get('ebitda') net_debt = stock_data.get('net_debt') ticker = stock_data.get('ticker', '') sector = stock_data.get('sector', '') # Compute FCF yield if possible fcf_yield = None if fcf is not None and market_cap: fcf_yield = fcf / market_cap # Compute net debt / EBITDA net_debt_ebitda = None if net_debt is not None and ebitda: if ebitda != 0: net_debt_ebitda = net_debt / ebitda # Sector-specific FCF yield handling for automotive/industrials (use 3y avg, softer threshold) is_auto_industrial = self._is_auto_industrial(ticker, sector) fcf_yield_to_check = fcf_yield hard_fail_fcf_threshold = self.HARD_FAIL_FCF_YIELD_THRESHOLD if is_auto_industrial: avg_fcf_yield = stock_data.get('avg_fcf_yield_3y') if avg_fcf_yield is not None: fcf_yield_to_check = avg_fcf_yield hard_fail_fcf_threshold = -0.05 # -5% on average over 3y # Hard fail condition if (fcf_yield_to_check is not None and fcf_yield_to_check < hard_fail_fcf_threshold) or \ (net_debt_ebitda is not None and net_debt_ebitda > self.HARD_FAIL_NET_DEBT_EBITDA_THRESHOLD): # PST.MI ha struttura ibrida: declassa a DATA INSUFFICIENT invece di CRITICAL if stock_data.get('ticker') == 'PST.MI': return { 'ticker': stock_data.get('ticker'), 'name': stock_data.get('name'), 'score': self.SCORE_DATA_INSUFFICIENT, 'valutazione': self.RATING_DATA_INSUFFICIENT, 'fair_value': round(stock_data.get('price', 0), 2), 'current_price': round(stock_data.get('price', 0), 2), 'margin_of_safety': 0.0, 'ragionamento': "PST.MI: Criticità FCF/Debito non standard per settore finanziario/ibrido. Classificazione declassata a Dati Insufficienti.", } return { 'ticker': stock_data.get('ticker'), 'name': stock_data.get('name'), 'score': self.SCORE_CRITICAL_DANGER, 'valutazione': self.RATING_CRITICAL_DANGER, 'fair_value': round(stock_data.get('price', 0), 2), 'current_price': round(stock_data.get('price', 0), 2), 'margin_of_safety': 0.0, 'ragionamento': "Dati critici: FCF yield o Net Debt/EBITDA fuori soglia.", } # Data completeness check (conditional on sector) missing_advanced = 0 if stock_data.get('ticker') not in self.SPECIAL_TREATMENT_TICKERS: for v in [fcf, ev, ebitda, net_debt]: if v is None: missing_advanced += 1 else: # For special tickers (financials/utilities), ignore FCF/EBITDA gaps # but require basic valuation inputs for v in [stock_data.get('pe_ratio'), stock_data.get('pb_ratio'), stock_data.get('roe'), stock_data.get('market_cap')]: if v is None: missing_advanced += 1 if missing_advanced > 2: return { 'ticker': stock_data.get('ticker'), 'name': stock_data.get('name'), 'score': self.SCORE_DATA_INSUFFICIENT, 'valutazione': self.RATING_DATA_INSUFFICIENT, 'fair_value': round(stock_data.get('price', 0), 2), 'current_price': round(stock_data.get('price', 0), 2), 'margin_of_safety': 0.0, 'ragionamento': "Dati incompleti: per questo settore mancano metriche fondamentali necessarie (non richiesti FCF/EBITDA per financial/utility).", } # === CIO QUALITY GATEKEEPER === cio_failures = self._check_cio_gatekeeper(stock_data, ticker, sector) if cio_failures: logger.warning(f"{ticker}: CIO Quality Fail - {', '.join(cio_failures)}") return { 'ticker': stock_data.get('ticker'), 'name': stock_data.get('name'), 'score': self.SCORE_CRITICAL_DANGER, 'valutazione': self.RATING_CRITICAL_DANGER, 'fair_value': round(stock_data.get('price', 0), 2), 'current_price': round(stock_data.get('price', 0), 2), 'margin_of_safety': 0.0, 'ragionamento': f"CIO Quality Fail: {', '.join(cio_failures)}", } # Calculate fair value fair_value = self.calculate_fair_value(stock_data) # Calculate margin of safety current_price = stock_data.get('price', 0) margin_of_safety = self._calculate_margin_of_safety(current_price, fair_value) # Calculate quality score score = self.calculate_score(stock_data) # Determine recommendation valutazione = self._get_recommendation(score, margin_of_safety) # Generate reasoning ragionamento = self._generate_reasoning(stock_data, score, fair_value, margin_of_safety, valutazione) return { 'ticker': stock_data.get('ticker'), 'name': stock_data.get('name'), 'score': score, 'valutazione': valutazione, 'fair_value': round(fair_value, 2), 'current_price': round(current_price, 2), 'margin_of_safety': round(margin_of_safety, 1), 'ragionamento': ragionamento } def calculate_fair_value(self, stock_data: Dict) -> float: """ Calculate fair value using multiple valuation methods (Schema v2) Uses a weighted average of: 1. P/E based valuation (35%) 2. P/B based valuation (25%) 3. FCF Yield valuation (20%) [NEW - Schema v2] 4. EV/EBITDA valuation (15%) [NEW - Schema v2] 5. Dividend discount model (5%) Weights auto-normalize when data is missing. Special handling for luxury/growth stocks (Ferrari, Moncler, etc.) """ price = stock_data.get('price', 0) pe = stock_data.get('pe_ratio') pb = stock_data.get('pb_ratio') roe = stock_data.get('roe') earnings_growth = stock_data.get('earnings_growth') dividend_yield = stock_data.get('dividend_yield') sector = stock_data.get('sector', '') ticker = stock_data.get('ticker', '') # Schema v2 fields market_cap = stock_data.get('market_cap') free_cashflow = stock_data.get('free_cashflow') enterprise_value = stock_data.get('enterprise_value') ebitda = stock_data.get('ebitda') valuations = [] weights = [] # Detect luxury/brand-premium stocks is_luxury = self._is_luxury_brand(ticker, sector) is_utility = self._is_utility_sector(ticker, sector) net_debt_val = stock_data.get('net_debt') roe_val = stock_data.get('roe') # Method 1: P/E based (Benjamin Graham formula) components = {} if pe and pe > 0 and earnings_growth is not None: # Handle negative earnings growth conservatively if earnings_growth < -0.2: logger.warning( f"{ticker}: Negative earnings growth ({earnings_growth:.1%}). " f"Using conservative P/E approach." ) growth_rate = 0 # Don't penalize too much, but don't reward else: growth_rate = earnings_growth * 100 # Convert to percentage growth_rate = max(0, growth_rate) # Floor at 0 # Cap growth contribution to avoid runaway fair P/E growth_rate = min(growth_rate, 5.0) # Max +5% for P/E model # Adjust fair P/E for luxury brands (they deserve premium multiples) if is_luxury: fair_pe = min(30, 20 + 2.0 * growth_rate) # Lower cap to avoid runaway else: fair_pe = min(18, 10 + 1.5 * growth_rate) # More conservative pe_fair_value = price * (fair_pe / pe) # Sanity check: don't let fair value be > 3x current price pe_fair_value = min(pe_fair_value, price * 3.0) valuations.append(pe_fair_value) weights.append(0.35) # 35% weight (reduced from 40%) components["pe"] = pe_fair_value # Method 2: P/B and ROE based if pb and pb > 0 and roe and roe > 0: # Fair P/B ratio based on ROE roe_pct = roe * 100 # Luxury brands can sustain higher P/B multiples if is_luxury: fair_pb = max(2.0, min(6.0, roe_pct / 5)) else: fair_pb = max(1.0, min(2.0, roe_pct / 12)) # Cap più conservativo pb_fair_value = price * (fair_pb / pb) # Sanity check pb_fair_value = min(pb_fair_value, price * 3.0) valuations.append(pb_fair_value) weights.append(0.25) # 25% weight (reduced from 30%) components["pb"] = pb_fair_value # Method 3: FCF Yield valuation (Schema v2) - 20% weight if free_cashflow and market_cap and market_cap > 0: # Calculate FCF Yield fcf_yield = free_cashflow / market_cap # Target FCF yield for quality stocks: 5-8% # Lower yield = higher valuation (stock is expensive) # Higher yield = lower valuation (stock is cheap) if fcf_yield > 0: target_fcf_yield = self.TARGET_FCF_YIELD # Fair value based on FCF yield normalization (lower yield -> higher FV) fcf_fair_value = price * (fcf_yield / target_fcf_yield) # Sanity check fcf_fair_value = min(fcf_fair_value, price * 3.0) fcf_fair_value = max(fcf_fair_value, price * 0.3) # Floor at 30% valuations.append(fcf_fair_value) weights.append(0.20) components["fcf_yield"] = fcf_fair_value # Method 4: EV/EBITDA valuation (Schema v2) - 15% weight if enterprise_value and ebitda and ebitda > 0 and enterprise_value > 0: # Calculate current EV/EBITDA multiple ev_ebitda_ratio = enterprise_value / ebitda # Target EV/EBITDA for quality stocks # Luxury brands can sustain higher multiples target_ev_ebitda = self.TARGET_EV_EBITDA_LUXURY if is_luxury else self.TARGET_EV_EBITDA # Fair value based on EV/EBITDA normalization # If current multiple is high, stock is expensive ev_fair_value = price * (target_ev_ebitda / ev_ebitda_ratio) # Sanity check ev_fair_value = min(ev_fair_value, price * 2.5) # Slightly tighter cap ev_fair_value = max(ev_fair_value, price * 0.4) # Slightly higher floor valuations.append(ev_fair_value) weights.append(0.15) components["ev_ebitda"] = ev_fair_value # Method 5: Dividend-based (for dividend payers) - 5% weight if dividend_yield and dividend_yield > 0: # Normalize dividend yield normalized_div_yield = dividend_yield if dividend_yield < 1 else dividend_yield / 100 # Fair dividend yield for quality stocks: 2-4% target_yield = 0.03 # 3% # Only use dividend valuation if yield is reasonable (0.5% - 15%) if 0.005 < normalized_div_yield < 0.15: div_fair_value = price * (target_yield / normalized_div_yield) # Sanity check div_fair_value = min(div_fair_value, price * 2.0) valuations.append(div_fair_value) weights.append(0.05) # 5% weight (reduced from 30%) components["dividend"] = div_fair_value # Calculate weighted average if valuations: total_weight = sum(weights) fair_value = sum(v * w for v, w in zip(valuations, weights)) / total_weight else: # Fallback: use current price if no data fair_value = price # Final floor: Fair value should be at least 20% of current price # (prevents absurd undervaluations for luxury/growth stocks) min_fair_value = price * 0.2 fair_value = max(min_fair_value, fair_value) base_fair_value = fair_value # Utilities defensive bonus: premia bassa beta o alto dividend yield if is_utility: beta = stock_data.get('beta') dividend_yield_val = stock_data.get('dividend_yield') bonus = 0.0 if beta is not None and beta < 0.8: bonus += 0.05 # +5% fair value per bassa volatilità if dividend_yield_val: normalized_div_yield = dividend_yield_val if dividend_yield_val < 1 else dividend_yield_val / 100 if normalized_div_yield > 0.05: bonus += 0.05 # +5% per yield sostenuto if bonus > 0: fair_value *= (1 + min(bonus, 0.10)) # Cap bonus al 10% # Quality Premium: premia aziende best-in-class (alto ROE + net cash) if fair_value > 0: roe_pct = roe_val * 100 if roe_val is not None else None multiplier = 1.0 # Esclusione finanziari dal premio ROE (ROE spesso alto per leva/regolazione) sector_l = sector.lower() if sector else '' is_financial = self._is_financial_sector(ticker, sector) or any( kw in sector_l for kw in ['financial', 'bank', 'insurance', 'asset'] ) # ROE premium solo per non-financial if roe_pct is not None and not is_financial: if roe_pct > 25: multiplier += 0.25 # +25% FV per ROE eccellente elif roe_pct > 15: multiplier += 0.10 # +10% FV per buon ROE # Net cash premium (valido per tutti) if net_debt_val is not None and net_debt_val < 0: multiplier += 0.10 # +10% FV per posizione net cash # Brand/lusso/consumer cyclical premium (solo non-financial) if roe_pct is not None and not is_financial: if (is_luxury or any(k in sector_l for k in ['luxury', 'leisure', 'consumer cyclical'])) and roe_pct > 20: multiplier += 0.15 multiplier = min(multiplier, 1.5) # Cap al +50% fair_value *= multiplier logger.debug( f"{ticker}: Quality premium applied", extra={ "ticker": ticker, "roe_pct": roe_pct, "net_debt": net_debt_val, "is_financial": is_financial, "is_luxury": is_luxury, "multiplier": multiplier, }, ) logger.debug( f"{ticker}: fair value components", extra={ "ticker": ticker, "components": components, "weights": weights, "base_fair_value": round(base_fair_value, 4), "final_fair_value": round(fair_value, 4), }, ) return max(0.01, fair_value) # Ensure positive value def _debt_penalty_sector_aware(self, ticker: str, debt_equity: Optional[float], net_debt_ebitda: Optional[float], sector: str) -> int: """ Sector-aware debt penalty: - Automotive/Consumer Cyclical/Financial Services: ignore D/E (spesso falsato), usa Net Debt/EBITDA più tollerante. - Altri settori: soglie standard su D/E e Net Debt/EBITDA. """ penalty = 0 sector_l = sector.lower() if sector else '' is_auto_fin = ( ticker in self.AUTOMOTIVE_TICKERS or any(k in sector_l for k in ['automotive', 'auto', 'consumer cyclical', 'financial services']) ) if is_auto_fin: if net_debt_ebitda is not None: if net_debt_ebitda > 5.0: penalty += 15 elif net_debt_ebitda > 3.5: penalty += 5 else: if debt_equity is not None and debt_equity > 1.5: penalty += 10 if net_debt_ebitda is not None and net_debt_ebitda > 3.0: penalty += 10 return penalty def _is_luxury_brand(self, ticker: str, sector: str) -> bool: """ Detect if stock is a luxury/brand-premium company These stocks trade at premium multiples due to: - Strong brand moat - Pricing power - Scarcity/exclusivity """ # Explicit luxury brand tickers (even if sector classification is generic) luxury_tickers = ['RACE.MI', 'MONC.MI'] # Ferrari, Moncler # Sector keywords that indicate luxury positioning luxury_keywords = ['Luxury', 'Consumer Cyclical'] return ticker in luxury_tickers or any(keyword in sector for keyword in luxury_keywords) def _is_auto_industrial(self, ticker: str, sector: str) -> bool: """Detect automotive/industrial sectors where FCF can be volatile YoY.""" sector_l = sector.lower() if sector else '' keywords = ['automotive', 'industrial', 'construction', 'machinery', 'capital goods'] return ( ticker in self.AUTOMOTIVE_TICKERS or ticker in self.INDUSTRIAL_TICKERS or any(k in sector_l for k in keywords) ) def _is_financial_sector(self, ticker: str, sector: str) -> bool: """ Detect if stock is in BANKING/INSURANCE sector (NOT asset management) These stocks need special metrics: - D/E is meaningless (deposits/reserves counted as debt) - Use P/B ratio instead - Different valuation multiples NOTE: Excludes asset management firms (like Azimut) which operate differently """ # ONLY banks and insurance - NOT asset managers! financial_tickers = self.FINANCIALS_TICKERS # Keywords that identify BANKS and INSURANCE only financial_keywords = [ 'Banks', 'Insurance', 'Diversified Banks', 'Regional Banks' ] return ticker in financial_tickers or any(keyword in sector for keyword in financial_keywords) def _is_utility_sector(self, ticker: str, sector: str) -> bool: """ Detect if stock is a utility company (Schema v2) Utilities have special characteristics: - High infrastructure costs → high debt is normal - Stable cashflows → dividends should be covered by FCF - Regulated pricing → lower growth but predictable Uses hybrid detection (hardcoded list + keywords) """ # Hardcoded utility tickers (per user configuration) utility_tickers = [ 'ENEL.MI', # Enel (energy) 'TRN.MI', # Terna (grid operator) 'SRG.MI', # Snam (gas infrastructure) 'HER.MI', # Hera (multi-utility) 'A2A.MI', # A2A (multi-utility) 'IG.MI', # Italgas (gas distribution) ] # Sector keywords for utilities utility_keywords = [ 'Utilities', 'Energy', 'Oil & Gas', 'Electric', 'Gas', ] return ticker in utility_tickers or any(keyword in sector for keyword in utility_keywords) def _check_cio_gatekeeper(self, stock_data: Dict, ticker: str, sector: str) -> list: """ CIO Quality Trio: Gatekeeper to avoid value traps. Returns list of failure reasons (empty = pass all checks). Three quality checks using snapshot data (Schema v2): 1. Cash King: OCF should support earnings (OCF >= Net Income) 2. Margin Check: Operating margin must be positive 3. Solvency Check: D/E within sector-appropriate thresholds """ failures = [] # CRITERIO 1: Cash King (Quality of Earnings) net_income = stock_data.get('net_income') operating_cf = stock_data.get('operating_cashflow') if net_income is not None and net_income > 0 and operating_cf is not None: if operating_cf < net_income: failures.append("Cash Flow Quality (OCF < Net Income)") # CRITERIO 2: Margin Check (Profitability) operating_margin = stock_data.get('operating_margin') if operating_margin is not None and operating_margin < 0: failures.append("Negative Operating Margin") # CRITERIO 3: Solvency Check (Debt Sector-Aware) debt_to_equity = stock_data.get('debt_to_equity') if debt_to_equity is not None: # Normalize D/E ratio: Yahoo Finance is inconsistent # Some tickers return ratio (0.5-3.0), others percentage (50-300) # Heuristic: if value > 10, assume it's percentage → divide by 100 de_ratio = debt_to_equity / 100.0 if debt_to_equity > 10 else debt_to_equity # Skip for Financials (D/E not meaningful for banks) if ticker in self.FINANCIALS_TICKERS: pass # No D/E check for financial institutions # High tolerance for Utilities (infrastructure-heavy) elif ticker in self.UTILITY_TICKERS: if de_ratio > 2.5: failures.append(f"High Debt/Equity ({de_ratio:.2f} > 2.5)") # Standard threshold for Industrial/Services else: if de_ratio > 1.5: failures.append(f"High Debt/Equity ({de_ratio:.2f} > 1.5)") return failures def calculate_score(self, stock_data: Dict) -> int: """ Calculate Warren Buffett quality score (0-100) Breakdown: - Valuation (30 points): P/E, P/B, Dividend yield - Quality (40 points): ROE, Debt/Equity - Growth (30 points): Revenue growth, Earnings growth """ score = 0 # === VALUATION SCORE (30 points) === pe = stock_data.get('pe_ratio') pb = stock_data.get('pb_ratio') div_yield = stock_data.get('dividend_yield', 0) # P/E scoring (15 points) if pe: if pe < 12: score += 15 elif pe < 18: score += 12 elif pe < 25: score += 8 elif pe < 35: score += 4 # P/B scoring (10 points) if pb: if pb < 1.5: score += 10 elif pb < 2.5: score += 7 elif pb < 4: score += 4 elif pb < 6: score += 2 # Dividend yield (5 points) if div_yield: # Normalize dividend yield (sometimes stored as percentage, sometimes as decimal) normalized_div = div_yield if div_yield < 1 else div_yield / 100 div_pct = normalized_div * 100 if div_pct > 4: score += 5 elif div_pct > 2.5: score += 4 elif div_pct > 1: score += 2 # === QUALITY SCORE (40 points) === roe = stock_data.get('roe') debt_to_equity = stock_data.get('debt_to_equity') pb = stock_data.get('pb_ratio') sector = stock_data.get('sector', '') ticker = stock_data.get('ticker', '') # ROE scoring (25 points) - Most important for Warren # VALUE TRAP DETECTION: Penalize negative ROE if roe is not None: roe_pct = roe * 100 if roe_pct > 20: score += 25 elif roe_pct > 15: score += 20 elif roe_pct > 10: score += 15 elif roe_pct > 5: score += 8 elif roe_pct > 0: score += 3 elif roe_pct > -5: score -= 5 # Small penalty for slightly negative ROE else: score -= 15 # HEAVY penalty for deeply negative ROE (value trap!) # Debt to Equity (15 points) - Warren prefers low debt # SPECIAL HANDLING: Financials sector (banks, insurance) # Treat utilities as special too is_financial = self._is_financial_sector(ticker, sector) or ticker in self.UTILITY_TICKERS if is_financial: # For financials, use P/B ratio instead of D/E # Banks/insurance have high D/E by nature (deposits/reserves) if pb is not None: if pb < 0.8: score += 15 # Trading below book value elif pb < 1.2: score += 12 # Around book value elif pb < 1.5: score += 8 elif pb < 2.0: score += 4 else: # Normal D/E scoring for non-financials if debt_to_equity is not None: if debt_to_equity < 0.3: score += 15 elif debt_to_equity < 0.6: score += 12 elif debt_to_equity < 1.0: score += 8 elif debt_to_equity < 2.0: score += 4 # Penalize very high debt (>3.0) elif debt_to_equity > 3.0: score -= 5 # === GROWTH SCORE (30 points) === revenue_growth = stock_data.get('revenue_growth') earnings_growth = stock_data.get('earnings_growth') # Revenue growth (15 points) if revenue_growth is not None: rev_pct = revenue_growth * 100 if rev_pct > 15: score += 15 elif rev_pct > 10: score += 12 elif rev_pct > 5: score += 9 elif rev_pct > 0: score += 5 elif rev_pct > -5: score += 2 # Earnings growth (15 points) if earnings_growth is not None: earn_pct = earnings_growth * 100 if earn_pct > 15: score += 15 elif earn_pct > 10: score += 12 elif earn_pct > 5: score += 9 elif earn_pct > 0: score += 5 elif earn_pct > -5: score += 2 # === SCHEMA V2 ENHANCEMENTS === # Detect utility sector once for all v2 logic is_utility = self._is_utility_sector(ticker, sector) # Margin-based scoring bonus (up to +10 points) gross_margin = stock_data.get('gross_margin') operating_margin = stock_data.get('operating_margin') if gross_margin is not None and operating_margin is not None: gross_pct = gross_margin * 100 operating_pct = operating_margin * 100 # High margins indicate pricing power and efficiency margin_bonus = 0 if gross_pct > 40 and operating_pct > 15: margin_bonus = 10 # Excellent margins (Ferrari-like) elif gross_pct > 30 and operating_pct > 10: margin_bonus = 7 # Strong margins elif gross_pct > 20 and operating_pct > 5: margin_bonus = 4 # Good margins score += margin_bonus # Net Debt/EBITDA scoring (up to +5 points bonus, -10 penalty) net_debt = stock_data.get('net_debt') ebitda = stock_data.get('ebitda') net_debt_ebitda_ratio = None if net_debt is not None and ebitda is not None and ebitda > 0: net_debt_ebitda_ratio = net_debt / ebitda # Sector-specific thresholds if is_utility: # Utilities: infrastructure-heavy, higher debt is normal if net_debt_ebitda_ratio < 2.0: score += 5 # Very low debt for utility elif net_debt_ebitda_ratio < 4.0: score += 3 # Normal debt for utility elif net_debt_ebitda_ratio > 6.0: score -= 10 # Excessive debt even for utility else: # Non-utilities: lower debt preferred if net_debt_ebitda_ratio < 1.0: score += 5 # Excellent debt coverage elif net_debt_ebitda_ratio < 2.0: score += 3 # Good debt coverage elif net_debt_ebitda_ratio > 4.0: score -= 10 # Excessive debt # Sector-aware debt penalty (applied after bonuses/penalties above) debt_penalty = self._debt_penalty_sector_aware(ticker, debt_to_equity, net_debt_ebitda_ratio, sector) score -= debt_penalty # Utilities-specific: Payout sustainability (FCF vs Dividend) if is_utility: free_cashflow = stock_data.get('free_cashflow') dividend_rate = stock_data.get('dividend_rate') shares_outstanding = stock_data.get('shares_outstanding') if free_cashflow and dividend_rate and shares_outstanding: total_dividends = dividend_rate * shares_outstanding payout_ratio_fcf = total_dividends / free_cashflow if free_cashflow > 0 else 0 # Utilities should pay dividends from FCF, not debt if payout_ratio_fcf < 0.7: score += 5 # Sustainable payout (room for growth) elif payout_ratio_fcf < 0.9: score += 2 # Acceptable payout elif payout_ratio_fcf > 1.2: score -= 10 # Paying dividends from debt (unsustainable!) # Clamp score to [0, 100] to avoid negative values after penalties return max(0, min(100, score)) def _calculate_margin_of_safety(self, current_price: float, fair_value: float) -> float: """Calculate margin of safety as percentage""" if fair_value <= 0 or current_price <= 0: return -100.0 margin = ((fair_value - current_price) / fair_value) * 100 # Cap extreme values to prevent display issues return max(-99.9, min(99.9, margin)) def _get_recommendation(self, score: int, margin_of_safety: float) -> str: """ Determine recommendation based on score and margin of safety Warren Buffett criteria: - STRONG BUY: Excellent business (score >= 80) + Exceptional price (margin >= 20%) - BUY: Excellent business (score >= 80) + Great price (margin >= 15%) - HOLD: Good business (score >= 60) OR decent margin - AVOID: Poor business (score < 60) AND no margin of safety """ if score >= self.BUY_SCORE and margin_of_safety >= self.STRONG_BUY_MARGIN: return "STRONG BUY" elif score >= self.BUY_SCORE and margin_of_safety >= self.BUY_MARGIN: return "BUY" elif score >= self.HOLD_SCORE: return "HOLD" else: return "AVOID" def _generate_reasoning( self, stock_data: Dict, score: int, fair_value: float, margin_of_safety: float, valutazione: str ) -> str: """Generate human-readable reasoning for the recommendation""" ticker = stock_data.get('ticker', '') name = stock_data.get('name', '') sector = stock_data.get('sector', 'N/A') price = stock_data.get('price', 0) pe = stock_data.get('pe_ratio') roe = stock_data.get('roe', 0) debt_eq = stock_data.get('debt_to_equity') debt_eq_reported = stock_data.get('debt_to_equity_reported') net_debt_val = stock_data.get('net_debt') # Start with valuation assessment if valutazione == "STRONG BUY": reasoning = f"{name} è un'eccezionale opportunità di valore: score {score}/100 con margin of safety straordinario del {margin_of_safety:.1f}%! " elif valutazione == "BUY": reasoning = f"{name} è un'opportunità eccellente: score {score}/100 con margin of safety del {margin_of_safety:.1f}%. " elif margin_of_safety > 0: # Positive margin = fair value > current price = undervalued if score >= 60: if margin_of_safety >= self.BUY_MARGIN: reasoning = ( f"{name} è un business di qualità (score {score}/100). " f"Il prezzo attuale di €{price:.2f} incorpora uno sconto del {margin_of_safety:.1f}% rispetto al fair value di €{fair_value:.2f}, " f"ma il punteggio non raggiunge ancora la soglia BUY ({self.BUY_SCORE}). " ) else: reasoning = ( f"{name} è un business di qualità (score {score}/100). " f"Il prezzo di €{price:.2f} offre uno sconto limitato del {margin_of_safety:.1f}% rispetto al fair value di €{fair_value:.2f}. " ) else: reasoning = ( f"{name} (score {score}/100) quota a €{price:.2f}, con uno sconto del {margin_of_safety:.1f}% rispetto al fair value stimato di €{fair_value:.2f}, " "ma la qualità del business non soddisfa i nostri standard. " ) else: # Negative margin = current price > fair value = overvalued reasoning = f"{name} presenta un premio del {abs(margin_of_safety):.1f}% rispetto al fair value stimato di €{fair_value:.2f}. " # Add quality assessment if roe: roe_pct = roe * 100 if roe_pct > 15: reasoning += f"Eccellente ROE del {roe_pct:.1f}% indica un business di qualità superiore. " elif roe_pct > 10: reasoning += f"ROE del {roe_pct:.1f}% mostra una buona redditività. " else: reasoning += f"ROE del {roe_pct:.1f}% è al di sotto degli standard desiderati. " # Add valuation comment if pe: if pe < 15: reasoning += f"P/E di {pe:.1f}x è attraente. " elif pe < 25: reasoning += f"P/E di {pe:.1f}x è nella norma. " else: reasoning += f"P/E di {pe:.1f}x è elevato. " # Add debt assessment if net_debt_val is not None and net_debt_val <= 0 and debt_eq_reported is not None and debt_eq_reported > 1.0: reasoning += f"Posizione net cash: D/E riportato {debt_eq_reported:.2f} distorto dalla cassa/depositi. " elif debt_eq is not None: display_de = debt_eq_reported if debt_eq_reported is not None else debt_eq if debt_eq < 0.5: reasoning += f"Bilancio solido con debt/equity di {display_de:.2f}. " elif debt_eq < 1.5: reasoning += f"Debt/equity di {display_de:.2f} è accettabile. " else: reasoning += f"Debt/equity di {display_de:.2f} richiede cautela. " # Final recommendation if valutazione == "STRONG BUY": reasoning += f"Questa è un'occasione rara - Acquistare immediatamente! Il margine di sicurezza del {margin_of_safety:.1f}% offre protezione eccezionale." elif valutazione == "BUY": reasoning += f"Raccomando l'acquisto a prezzi attuali." elif valutazione == "HOLD": entry_price = fair_value * 0.8 if margin_of_safety >= self.BUY_MARGIN: if price <= entry_price: reasoning += ( f"Prezzo già sotto la soglia d'ingresso (€{entry_price:.2f}); " f"serve che la qualità superi {self.BUY_SCORE}/100 per promuoverla a BUY." ) else: reasoning += ( f"Tenere in watchlist; ingresso ideale sotto €{entry_price:.2f} " f"o se il punteggio sale oltre {self.BUY_SCORE}/100." ) else: reasoning += f"Tenere in watchlist, attendere un prezzo d'ingresso migliore sotto €{entry_price:.2f}." else: reasoning += f"Evitare al prezzo attuale, non soddisfa i criteri di qualità e prezzo." return reasoning