#!/usr/bin/env python3 # /// script # requires-python = ">=3.10" # dependencies = [ # "yfinance>=0.2.40", # "pandas>=2.0.0", # "longport>=2.0.0", # "fear-and-greed>=0.4", # "edgartools>=2.0.0", # "feedparser>=6.0.0", # ] # /// """ Stock analysis with 8-dimension scoring: LongPort (primary) + Yahoo Finance (fallback). Usage: uv run analyze_stock_unified.py TICKER [TICKER2 ...] [--output text|json] [--verbose] [--fast] """ import argparse import asyncio import json import sys from dataclasses import dataclass, asdict from datetime import datetime from typing import Literal, Optional import pandas as pd # Import unified data source from data_source import fetch_stock_data_unified, UnifiedStockData # Import original analysis functions (copy from analyze_stock.py) # We'll create adapters to work with UnifiedStockData @dataclass class EarningsSurprise: score: float explanation: str actual_eps: float | None = None expected_eps: float | None = None surprise_pct: float | None = None @dataclass class Fundamentals: score: float key_metrics: dict explanation: str @dataclass class AnalystSentiment: score: float | None summary: str consensus_rating: str | None = None price_target: float | None = None current_price: float | None = None upside_pct: float | None = None num_analysts: int | None = None @dataclass class MomentumAnalysis: rsi_14d: float | None rsi_status: str price_vs_52w_low: float | None price_vs_52w_high: float | None near_52w_high: bool near_52w_low: bool volume_ratio: float | None score: float explanation: str @dataclass class Signal: ticker: str company_name: str recommendation: Literal["BUY", "HOLD", "SELL"] confidence: float final_score: float supporting_points: list[str] caveats: list[str] timestamp: str components: dict def analyze_fundamentals_from_unified(data: UnifiedStockData) -> Fundamentals | None: """Analyze fundamentals from unified data source.""" scores = [] metrics = {} explanations = [] try: # PE Ratio (LongPort) if data.pe_ttm and data.pe_ttm > 0: metrics["pe_ttm"] = float(data.pe_ttm) if data.pe_ttm < 15: scores.append(0.5) explanations.append(f"Attractive PE: {float(data.pe_ttm):.1f}x") elif data.pe_ttm > 30: scores.append(-0.3) explanations.append(f"Elevated PE: {float(data.pe_ttm):.1f}x") else: scores.append(0.1) # PB Ratio (LongPort) if data.pb and data.pb > 0: metrics["pb"] = float(data.pb) if data.pb < 1.0: scores.append(0.6) explanations.append(f"Below book value: PB {float(data.pb):.2f}") elif data.pb < 2.0: scores.append(0.3) elif data.pb > 5.0: scores.append(-0.4) explanations.append(f"High PB: {float(data.pb):.1f}x") # Dividend Yield (LongPort) if data.dividend_yield: metrics["dividend_yield"] = float(data.dividend_yield) if data.dividend_yield > 5: scores.append(0.5) explanations.append(f"High dividend: {float(data.dividend_yield):.1f}%") elif data.dividend_yield > 3: scores.append(0.3) elif data.dividend_yield < 1: scores.append(-0.2) # Operating Margin (Yahoo fallback) if data.operating_margin: metrics["operating_margin"] = float(data.operating_margin) if data.operating_margin > 0.15: scores.append(0.5) explanations.append(f"Strong margin: {float(data.operating_margin)*100:.1f}%") elif data.operating_margin < 0.05: scores.append(-0.5) explanations.append(f"Weak margin: {float(data.operating_margin)*100:.1f}%") # ROE (Yahoo fallback) if data.roe: metrics["roe"] = float(data.roe) if data.roe > 0.15: scores.append(0.4) explanations.append(f"Strong ROE: {float(data.roe)*100:.1f}%") elif data.roe < 0.05: scores.append(-0.3) # Debt to Equity (Yahoo fallback) if data.debt_to_equity: metrics["debt_to_equity"] = float(data.debt_to_equity) if data.debt_to_equity < 50: scores.append(0.3) elif data.debt_to_equity > 200: scores.append(-0.5) explanations.append(f"High debt: D/E {float(data.debt_to_equity)/100:.1f}x") if not scores: return None avg_score = sum(scores) / len(scores) normalized_score = max(-1.0, min(1.0, avg_score)) return Fundamentals( score=normalized_score, key_metrics=metrics, explanation="; ".join(explanations) if explanations else "Mixed fundamentals", ) except Exception as e: print(f"Fundamentals analysis error: {e}", file=sys.stderr) return None def analyze_momentum_from_unified(data: UnifiedStockData) -> MomentumAnalysis | None: """Analyze momentum from unified data source.""" try: # Use volume_ratio from LongPort volume_ratio = float(data.volume_ratio) if data.volume_ratio else None # Calculate RSI from price history if available rsi_14d = None rsi_status = "neutral" if data.price_history is not None and len(data.price_history) >= 14: close_prices = data.price_history["Close"] delta = close_prices.diff() gain = (delta.where(delta > 0, 0)).rolling(window=14).mean() loss = (-delta.where(delta < 0, 0)).rolling(window=14).mean() rs = gain / loss rsi = 100 - (100 / (1 + rs)) rsi_14d = float(rsi.iloc[-1]) if rsi_14d > 70: rsi_status = "overbought" elif rsi_14d < 30: rsi_status = "oversold" # Score based on available metrics scores = [] if rsi_14d: if rsi_14d < 30: scores.append(0.5) # Oversold = opportunity elif rsi_14d > 70: scores.append(-0.5) # Overbought = risk if volume_ratio: if volume_ratio > 1.5: scores.append(0.3) # High volume = strong move elif volume_ratio < 0.5: scores.append(-0.2) # Low volume = weak move if data.change_rate: change = float(data.change_rate) if abs(change) > 5: scores.append(0.2 if change > 0 else -0.2) score = sum(scores) / len(scores) if scores else 0.0 return MomentumAnalysis( rsi_14d=rsi_14d, rsi_status=rsi_status, price_vs_52w_low=None, price_vs_52w_high=None, near_52w_high=False, near_52w_low=False, volume_ratio=volume_ratio, score=max(-1.0, min(1.0, score)), explanation=f"RSI: {rsi_14d:.1f} ({rsi_status})" if rsi_14d else "Limited momentum data", ) except Exception as e: print(f"Momentum analysis error: {e}", file=sys.stderr) return None def synthesize_signal( ticker: str, company_name: str, fundamentals: Fundamentals | None, momentum: MomentumAnalysis | None, data: UnifiedStockData, ) -> Signal: """Synthesize final signal from analysis components.""" scores = [] weights = [] supporting_points = [] caveats = [] # Fundamentals (40% weight) if fundamentals: scores.append(fundamentals.score) weights.append(0.40) if fundamentals.score > 0.3: supporting_points.append(f"✓ Strong fundamentals: {fundamentals.explanation}") elif fundamentals.score < -0.3: caveats.append(f"⚠ Weak fundamentals: {fundamentals.explanation}") # Valuation (30% weight) - from PE/PB/Dividend valuation_score = 0 valuation_count = 0 if data.pe_ttm and data.pe_ttm > 0: if data.pe_ttm < 15: valuation_score += 0.5 elif data.pe_ttm < 25: valuation_score += 0.2 elif data.pe_ttm > 35: valuation_score -= 0.3 valuation_count += 1 if data.pb: if data.pb < 1.0: valuation_score += 0.6 elif data.pb < 2.0: valuation_score += 0.3 elif data.pb > 5.0: valuation_score -= 0.4 valuation_count += 1 if data.dividend_yield: if data.dividend_yield > 5: valuation_score += 0.5 elif data.dividend_yield > 3: valuation_score += 0.3 valuation_count += 1 if valuation_count > 0: avg_valuation = valuation_score / valuation_count scores.append(avg_valuation) weights.append(0.30) if data.dividend_yield and data.dividend_yield > 5: supporting_points.append(f"✓ High dividend yield: {float(data.dividend_yield):.1f}%") # Momentum (20% weight) if momentum: scores.append(momentum.score) weights.append(0.20) if momentum.rsi_status == "oversold": supporting_points.append(f"✓ Oversold RSI: {momentum.rsi_14d:.1f}") elif momentum.rsi_status == "overbought": caveats.append(f"⚠ Overbought RSI: {momentum.rsi_14d:.1f}") # Market cap consideration (10% weight) if data.market_cap: cap = float(data.market_cap) if cap > 10e9: # Large cap scores.append(0.3) supporting_points.append("✓ Large-cap stability") elif cap < 1e9: # Small cap scores.append(-0.2) caveats.append("⚠ Small-cap volatility") weights.append(0.10) # Calculate final score if scores: final_score = sum(s * w for s, w in zip(scores, weights)) / sum(weights) else: final_score = 0.0 # Determine recommendation if final_score > 0.3: recommendation = "BUY" confidence = min(0.9, 0.5 + final_score) elif final_score > 0.0: recommendation = "BUY" confidence = 0.5 + final_score elif final_score > -0.3: recommendation = "HOLD" confidence = 0.5 - final_score else: recommendation = "SELL" confidence = min(0.9, 0.5 - final_score) # Add data source info supporting_points.append(f"📊 Data: {', '.join(data.data_sources)}") return Signal( ticker=ticker, company_name=company_name, recommendation=recommendation, confidence=round(confidence, 2), final_score=round(final_score, 3), supporting_points=supporting_points[:5], caveats=caveats[:5], timestamp=datetime.now().isoformat(), components={ "fundamentals": asdict(fundamentals) if fundamentals else None, "momentum": asdict(momentum) if momentum else None, "valuation": { "pe_ttm": float(data.pe_ttm) if data.pe_ttm else None, "pb": float(data.pb) if data.pb else None, "dividend_yield": float(data.dividend_yield) if data.dividend_yield else None, }, }, ) def format_output_text(signal: Signal) -> str: """Format signal as text output.""" lines = [ "=" * 60, f"📊 {signal.ticker} - {signal.company_name}", f"Generated: {signal.timestamp}", "=" * 60, "", f"📋 RECOMMENDATION: {signal.recommendation} (Confidence: {signal.confidence*100:.0f}%)", f"⭐ SCORE: {signal.final_score:+.3f}", "", "✅ SUPPORTING POINTS:", ] for point in signal.supporting_points: lines.append(f" {point}") lines.extend(["", "⚠️ CAVEATS:"]) for caveat in signal.caveats: lines.append(f" {caveat}") # Add component details if signal.components.get("fundamentals"): fund = signal.components["fundamentals"] lines.extend([ "", "📈 FUNDAMENTALS:", f" Score: {fund['score']:+.2f}", f" {fund['explanation']}", ]) if signal.components.get("valuation"): val = signal.components["valuation"] lines.extend([ "", "💰 VALUATION:", f" PE TTM: {val['pe_ttm']:.2f}" if val['pe_ttm'] else " PE TTM: N/A", f" PB: {val['pb']:.2f}" if val['pb'] else " PB: N/A", f" Dividend: {val['dividend_yield']:.1f}%" if val['dividend_yield'] else " Dividend: N/A", ]) lines.extend([ "", "=" * 60, "⚠️ NOT FINANCIAL ADVICE. For informational purposes only.", "=" * 60, ]) return "\n".join(lines) def format_output_json(signal: Signal) -> str: """Format signal as JSON.""" return json.dumps(asdict(signal), indent=2, default=str) def main(): parser = argparse.ArgumentParser( description="Stock analysis with 8-dimension scoring (LongPort + Yahoo)" ) parser.add_argument("tickers", nargs="+", help="Stock tickers") parser.add_argument("--output", choices=["text", "json"], default="text") parser.add_argument("--verbose", "-v", action="store_true") parser.add_argument("--fast", action="store_true", help="Skip slow analyses") args = parser.parse_args() results = [] for ticker in args.tickers: ticker = ticker.upper() if args.verbose: print(f"\n=== Analyzing {ticker} ===", file=sys.stderr) # Fetch unified data data = fetch_stock_data_unified(ticker, verbose=args.verbose) if data is None: print(f"Error: Failed to fetch data for {ticker}", file=sys.stderr) continue # Run analyses if args.verbose: print(" Analyzing fundamentals...", file=sys.stderr) fundamentals = analyze_fundamentals_from_unified(data) if args.verbose: print(" Analyzing momentum...", file=sys.stderr) momentum = analyze_momentum_from_unified(data) # Synthesize signal signal = synthesize_signal( ticker=data.symbol, company_name=data.name, fundamentals=fundamentals, momentum=momentum, data=data, ) results.append(signal) if args.output == "text": print(format_output_text(signal)) if args.output == "json": if len(results) == 1: print(format_output_json(results[0])) else: print(json.dumps([asdict(r) for r in results], indent=2, default=str)) if __name__ == "__main__": main()