#!/usr/bin/env python3 """ Enhanced yfinance wrapper with improved rate limit handling and intelligent caching. Uses dynamic proxy pattern to wrap all yfinance functionality automatically. """ import os import time import random import threading import pickle import hashlib from pathlib import Path from typing import Dict, Optional, Any, Union, List from dataclasses import dataclass from functools import wraps import logging from datetime import datetime, timedelta import yfinance as yf from curl_cffi import requests import pandas as pd @dataclass class RequestConfig: """Configuration for enhanced requests""" max_retries: int = 3 base_delay: float = 1.0 max_delay: float = 60.0 jitter: bool = True user_agents: list = None enable_cache: bool = True cache_dir: str = None def __post_init__(self): if self.user_agents is None: self.user_agents = [ 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2.1 Safari/605.1.15', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0', 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0' ] if self.cache_dir is None: self.cache_dir = os.path.expanduser('~/.yfinance_cache') class HistoricalDataCache: """Intelligent cache for historical data""" def __init__(self, cache_dir: str): self.cache_dir = Path(cache_dir) self.cache_dir.mkdir(parents=True, exist_ok=True) self._lock = threading.Lock() def _get_cache_key(self, symbol: str, period: str, interval: str, start: str = None, end: str = None) -> str: """Generate cache key for historical data request""" key_data = f"{symbol}_{period}_{interval}_{start}_{end}" return hashlib.md5(key_data.encode()).hexdigest() def _get_cache_file(self, cache_key: str) -> Path: """Get cache file path""" return self.cache_dir / f"{cache_key}.pkl" def _normalize_period_to_days(self, period: str) -> int: """Convert period string to approximate days""" if period is None: return 30 period_map = { '1d': 1, '2d': 2, '5d': 5, '1mo': 30, '3mo': 90, '6mo': 180, '1y': 365, '2y': 730, '5y': 1825, '10y': 3650, 'ytd': 365, 'max': 36500 } return period_map.get(str(period).lower(), 30) def get(self, symbol: str, period: str, interval: str, start: str = None, end: str = None) -> Optional[pd.DataFrame]: """Get cached historical data if available and valid""" if period is None: return None with self._lock: try: # First check exact match cache_key = self._get_cache_key(symbol, period, interval, start, end) cache_file = self._get_cache_file(cache_key) if cache_file.exists(): with open(cache_file, 'rb') as f: cache_data = pickle.load(f) # Check if cache is still valid (not older than 1 hour for recent data) cache_time = datetime.fromtimestamp(cache_file.stat().st_mtime) if datetime.now() - cache_time < timedelta(hours=1): return cache_data['data'] # Look for longer period cache that might contain requested data requested_days = self._normalize_period_to_days(period) for cache_file in self.cache_dir.glob(f"*.pkl"): try: with open(cache_file, 'rb') as f: cache_data = pickle.load(f) # Check if this cache contains data for the same symbol and interval if (cache_data['symbol'] == symbol and cache_data['interval'] == interval and cache_data['period_days'] >= requested_days): # Check cache validity cache_time = datetime.fromtimestamp(cache_file.stat().st_mtime) if datetime.now() - cache_time < timedelta(hours=1): # Extract requested period from cached data data = cache_data['data'] if len(data) >= requested_days: return data.tail(min(len(data), requested_days * 2)) # Buffer for weekends except: continue return None except Exception as e: logging.warning(f"Cache read error: {e}") return None def store(self, symbol: str, period: str, interval: str, data: pd.DataFrame, start: str = None, end: str = None): """Store historical data in cache""" if period is None: return with self._lock: try: # Check if we already have a longer period cache that contains this data requested_days = self._normalize_period_to_days(period) # Look for existing caches that might already contain this data for cache_file in self.cache_dir.glob(f"*.pkl"): try: with open(cache_file, 'rb') as f: existing_cache = pickle.load(f) # Check if existing cache already covers this request if (existing_cache['symbol'] == symbol and existing_cache['interval'] == interval and existing_cache['period_days'] >= requested_days): # Check if existing cache is recent enough cache_time = datetime.fromtimestamp(cache_file.stat().st_mtime) if datetime.now() - cache_time < timedelta(hours=1): # We already have this data in a longer period cache logging.info(f"Skipping cache store - data already exists in {cache_file.name}") return except: continue # Store new cache if no suitable existing cache found cache_key = self._get_cache_key(symbol, period, interval, start, end) cache_file = self._get_cache_file(cache_key) cache_data = { 'symbol': symbol, 'period': period, 'period_days': self._normalize_period_to_days(period), 'interval': interval, 'start': start, 'end': end, 'data': data, 'cached_at': datetime.now() } with open(cache_file, 'wb') as f: pickle.dump(cache_data, f) # Clean up redundant caches (smaller periods for same symbol/interval) self._cleanup_redundant_caches(symbol, interval, requested_days, cache_key) except Exception as e: logging.warning(f"Cache store error: {e}") def _cleanup_redundant_caches(self, symbol: str, interval: str, new_period_days: int, new_cache_key: str): """Clean up redundant caches that are covered by the new cache""" try: for cache_file in self.cache_dir.glob(f"*.pkl"): # Skip the new cache file if cache_file.name == f"{new_cache_key}.pkl": continue try: with open(cache_file, 'rb') as f: cache_data = pickle.load(f) # Remove smaller period caches for the same symbol/interval if (cache_data['symbol'] == symbol and cache_data['interval'] == interval and cache_data['period_days'] < new_period_days): cache_file.unlink() logging.info(f"Removed redundant cache: {cache_file.name}") except: continue except Exception as e: logging.warning(f"Cache cleanup error: {e}") def clear(self): """Clear all cached data""" with self._lock: for cache_file in self.cache_dir.glob("*.pkl"): try: cache_file.unlink() except: pass def get_cache_info(self) -> Dict[str, Any]: """Get cache statistics""" with self._lock: cache_files = list(self.cache_dir.glob("*.pkl")) total_size = sum(f.stat().st_size for f in cache_files) return { 'cache_dir': str(self.cache_dir), 'file_count': len(cache_files), 'total_size_mb': total_size / (1024 * 1024), 'files': [{ 'name': f.name, 'size_kb': f.stat().st_size / 1024, 'modified': datetime.fromtimestamp(f.stat().st_mtime) } for f in cache_files] } # Browser fingerprint profiles for session pool diversification _BROWSER_PROFILES = [ { "impersonate": "chrome120", "sec_ch_ua": '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"', "sec_ch_ua_platform": '"macOS"', "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", }, { "impersonate": "chrome110", "sec_ch_ua": '"Not_A Brand";v="8", "Chromium";v="110", "Google Chrome";v="110"', "sec_ch_ua_platform": '"Windows"', "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/110.0.0.0 Safari/537.36", }, { "impersonate": "edge99", "sec_ch_ua": '"Not A;Brand";v="99", "Chromium";v="99", "Microsoft Edge";v="99"', "sec_ch_ua_platform": '"Windows"', "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36 Edg/99.0.1150.36", }, { "impersonate": "safari15_5", "sec_ch_ua": None, # Safari doesn't send sec-ch-ua "sec_ch_ua_platform": None, "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 12_4) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.5 Safari/605.1.15", }, { "impersonate": "firefox102", "sec_ch_ua": None, # Firefox doesn't send sec-ch-ua "sec_ch_ua_platform": None, "user_agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:102.0) Gecko/20100101 Firefox/102.0", }, { "impersonate": "chrome120", "sec_ch_ua": '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"', "sec_ch_ua_platform": '"Linux"', "user_agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", }, ] # Module-level adaptive throttle state (shared across all Ticker() instances) _global_throttle_lock = threading.Lock() _global_last_request_time: float = 0.0 _global_request_count: int = 0 _global_min_delay: float = 0.3 # baseline delay (seconds) _global_rate_limit_count: int = 0 # consecutive rate limit hits _global_last_success_time: float = 0.0 def _global_rate_limit_delay(): """Module-level shared rate limiter — ensures all Ticker() instances are coordinated.""" global _global_last_request_time, _global_request_count, _global_min_delay, _global_last_success_time with _global_throttle_lock: current_time = time.time() time_since_last = current_time - _global_last_request_time delay_needed = _global_min_delay - time_since_last if delay_needed > 0: jitter = random.uniform(0, delay_needed * 0.2) time.sleep(delay_needed + jitter) _global_last_request_time = time.time() _global_request_count += 1 def _global_on_rate_limit(): """Called when a rate limit is detected — increases global delay adaptively.""" global _global_min_delay, _global_rate_limit_count with _global_throttle_lock: _global_rate_limit_count += 1 # Increase delay: 0.3 → 1.0 → 2.0 → 3.0 (cap at 5.0) _global_min_delay = min(_global_min_delay * 2.0 + 0.5, 5.0) def _global_on_success(): """Called on successful request — gradually reduces delay back toward baseline.""" global _global_min_delay, _global_rate_limit_count, _global_last_success_time with _global_throttle_lock: _global_last_success_time = time.time() if _global_min_delay > 0.3: # Slowly recover: reduce by 10% per success, floor at 0.3 _global_min_delay = max(_global_min_delay * 0.9, 0.3) class EnhancedYFinance: """Enhanced yfinance wrapper with better rate limit handling and caching""" def __init__(self, config: RequestConfig = None): self.config = config or RequestConfig() self._session_pool: List[requests.Session] = [] self._session_pool_lock = threading.Lock() self._session_pool_size = 4 self._session_index = 0 self._lock = threading.Lock() # kept for backwards-compat only # Setup caching if self.config.enable_cache: self._cache = HistoricalDataCache(self.config.cache_dir) else: self._cache = None # Setup logging self.logger = logging.getLogger(__name__) if not self.logger.handlers: handler = logging.StreamHandler() formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') handler.setFormatter(formatter) self.logger.addHandler(handler) self.logger.setLevel(logging.INFO) # Show cache operations # Suppress yfinance HTTP error logs yf_logger = logging.getLogger('yfinance') yf_logger.setLevel(logging.CRITICAL) # Pre-build session pool self._init_session_pool() def _init_session_pool(self): """Build a pool of sessions with diverse browser fingerprints.""" profiles = random.sample(_BROWSER_PROFILES, min(self._session_pool_size, len(_BROWSER_PROFILES))) with self._session_pool_lock: self._session_pool = [self._create_session_from_profile(p) for p in profiles] def _create_session_from_profile(self, profile: dict) -> requests.Session: """Create a session mimicking a specific browser profile.""" try: session = requests.Session(impersonate=profile["impersonate"]) except Exception: session = requests.Session(impersonate="chrome") headers = { 'User-Agent': profile["user_agent"], 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', 'Accept-Language': random.choice([ 'en-US,en;q=0.9', 'en-US,en;q=0.9,ko;q=0.8', 'en-GB,en;q=0.9', 'en-US,en;q=0.8,ja;q=0.6', ]), 'Accept-Encoding': 'gzip, deflate, br', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Origin': 'https://finance.yahoo.com', 'Referer': 'https://finance.yahoo.com/', } if profile.get("sec_ch_ua"): headers['Sec-Ch-Ua'] = profile["sec_ch_ua"] headers['Sec-Ch-Ua-Mobile'] = '?0' headers['Sec-Ch-Ua-Platform'] = profile["sec_ch_ua_platform"] headers['Sec-Fetch-Dest'] = 'document' headers['Sec-Fetch-Mode'] = 'navigate' headers['Sec-Fetch-Site'] = 'cross-site' session.headers.update(headers) # Unique cookies per session to diversify fingerprint current_time = int(time.time()) uid = ''.join(random.choices('ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789', k=22)) session.cookies.update({ 'A1': f'd=AQABBC{uid[:6]}YLoCIgEBwQJ7vgAB&S=AQAAAg&j=WORLD', 'A1S': f'd=AQABBC{uid[6:12]}YLoCIgEBwQJ7vgAB&S=AQAAAg', 'A3': f'd=AQABBC{uid[12:18]}YLoCIgEBwQJ7vgAB&S=AQAAAg', 'GUC': f'AQEBAQFm{uid[18:22]}k0L', 'B': f'c={random.randint(1000000, 9999999)}&b=3&s=4u', 'cmp': f't={current_time - random.randint(0, 86400)}&j=0&u=1---', }) return session @property def session(self) -> requests.Session: """Get next session from pool (round-robin), refreshing stale sessions.""" with self._session_pool_lock: if not self._session_pool: self._init_session_pool() idx = self._session_index % len(self._session_pool) self._session_index += 1 return self._session_pool[idx] def _rotate_session_on_ratelimit(self): """Replace the current session slot with a fresh one after a rate limit.""" with self._session_pool_lock: profile = random.choice(_BROWSER_PROFILES) idx = self._session_index % len(self._session_pool) self._session_pool[idx] = self._create_session_from_profile(profile) def _rate_limit_delay(self): """Delegate to module-level shared rate limiter.""" _global_rate_limit_delay() def _retry_with_backoff(self, func, *args, on_rate_limit_cb=None, **kwargs): """Execute function with exponential backoff retry and adaptive throttling. Args: func: callable to execute on_rate_limit_cb: optional zero-arg callback called when a rate limit is detected (e.g. to refresh the caller's yf.Ticker) """ last_exception = None for attempt in range(self.config.max_retries + 1): try: self._rate_limit_delay() result = func(*args, **kwargs) _global_on_success() return result except Exception as e: last_exception = e error_str = str(e).lower() # Check if it's a rate limit or 401 error if ("rate limit" in error_str or "429" in error_str or "401" in error_str or "unauthorized" in error_str): _global_on_rate_limit() if attempt < self.config.max_retries: delay = min( self.config.base_delay * (2 ** attempt), self.config.max_delay ) if self.config.jitter: delay += random.uniform(0, delay * 0.3) if "401" in error_str: self.logger.debug(f"401 error, rotating session and retrying in {delay:.2f}s (attempt {attempt + 1})") else: self.logger.warning(f"Rate limit hit, rotating session and retrying in {delay:.2f}s (attempt {attempt + 1})") time.sleep(delay) self._rotate_session_on_ratelimit() if on_rate_limit_cb is not None: on_rate_limit_cb() continue else: # Non-rate-limit error, re-raise immediately raise e # All retries exhausted raise last_exception def get_ticker(self, symbol: str) -> 'EnhancedTicker': """Get enhanced ticker with improved rate limiting""" return EnhancedTicker(symbol, self) class EnhancedTicker: """ Enhanced ticker wrapper using dynamic proxy pattern. Automatically wraps ALL yfinance ticker functionality. """ # Methods that should be cached _CACHEABLE_METHODS = {'history'} # Methods that should NOT be cached (real-time or frequently changing data) _NON_CACHEABLE_METHODS = {'news', 'get_news', 'fast_info'} def __init__(self, symbol: str, enhanced_yf: EnhancedYFinance): self.symbol = symbol.upper() self.enhanced_yf = enhanced_yf # Each EnhancedTicker gets a dedicated session from the pool at creation # time, ensuring no session is shared between concurrent threads. self._dedicated_session = enhanced_yf.session self._yf_ticker = yf.Ticker(self.symbol, session=self._dedicated_session) def _refresh_yf_ticker(self): """Get a fresh session from the pool and rebuild the underlying yf.Ticker. Called after a rate-limit event to rotate to a different browser fingerprint. """ self._dedicated_session = self.enhanced_yf.session self._yf_ticker = yf.Ticker(self.symbol, session=self._dedicated_session) @property def yf_ticker(self): """Return the cached yf.Ticker (thread-safe: each instance has its own session).""" return self._yf_ticker def __getattr__(self, name): """ Dynamic proxy: automatically wrap any yfinance method or property """ # Get the original attribute from yfinance ticker original_attr = getattr(self.yf_ticker, name) # If it's a method/function, wrap it with enhanced functionality if callable(original_attr): if name == 'history': # Special handling for history method with caching return self._enhanced_history elif name in self._NON_CACHEABLE_METHODS: # Methods that should not be cached return self._wrap_method_no_cache(original_attr) else: # All other methods get standard enhancement return self._wrap_method(original_attr) else: # For properties, wrap them with retry logic return self._wrap_property(name) def _enhanced_history(self, period="1mo", interval="1d", start=None, end=None, **kwargs): """Enhanced history method with caching""" # Check cache first for historical data (not real-time) if (self.enhanced_yf._cache and not kwargs.get('prepost', False)): # Don't cache extended hours data cached_data = self.enhanced_yf._cache.get( self.symbol, period, interval, str(start) if start else None, str(end) if end else None ) if cached_data is not None and not cached_data.empty: self.enhanced_yf.logger.info(f"Using cached data for {self.symbol}") return cached_data # Fetch fresh data def _get_history(): return self.yf_ticker.history( period=period, interval=interval, start=start, end=end, **kwargs ) data = self.enhanced_yf._retry_with_backoff( _get_history, on_rate_limit_cb=self._refresh_yf_ticker ) # Cache the data if successful and caching is enabled if (self.enhanced_yf._cache and data is not None and not data.empty and not kwargs.get('prepost', False)): self.enhanced_yf._cache.store( self.symbol, period, interval, data, str(start) if start else None, str(end) if end else None ) return data def _wrap_method(self, method): """Wrap a method with enhanced error handling and rate limiting""" @wraps(method) def wrapped(*args, **kwargs): def _call_method(): return method(*args, **kwargs) return self.enhanced_yf._retry_with_backoff(_call_method) return wrapped def _wrap_method_no_cache(self, method): """Wrap a method with enhanced error handling but no caching""" @wraps(method) def wrapped(*args, **kwargs): def _call_method(): return method(*args, **kwargs) return self.enhanced_yf._retry_with_backoff(_call_method) return wrapped def _wrap_property(self, name): """Wrap a property with enhanced error handling""" def _get_property(): return getattr(self.yf_ticker, name) return self.enhanced_yf._retry_with_backoff(_get_property) # Additional utility methods def get_cache_info(self) -> Dict[str, Any]: """Get cache information for this ticker""" if self.enhanced_yf._cache: return self.enhanced_yf._cache.get_cache_info() return {'cache_enabled': False} def clear_cache(self): """Clear cache for this ticker""" if self.enhanced_yf._cache: self.enhanced_yf._cache.clear() def download(tickers: Union[str, list], period: str = "1mo", interval: str = "1d", **kwargs) -> pd.DataFrame: """Enhanced download function with better rate limiting""" enhanced_yf = _get_global_enhanced_yf() def _download(): return yf.download( tickers=tickers, period=period, interval=interval, session=enhanced_yf.session, **kwargs ) return enhanced_yf._retry_with_backoff(_download) # Global configuration _global_config = RequestConfig() # Module-level singleton EnhancedYFinance — shared by all Ticker() calls so that # _global_rate_limit_delay() and the session pool are coordinated across all # concurrent requests (e.g., multiple FastAPI handler coroutines running in # executor threads at the same time). _global_enhanced_yf: Optional['EnhancedYFinance'] = None _global_enhanced_yf_lock = threading.Lock() def _get_global_enhanced_yf() -> 'EnhancedYFinance': global _global_enhanced_yf if _global_enhanced_yf is None: with _global_enhanced_yf_lock: if _global_enhanced_yf is None: _global_enhanced_yf = EnhancedYFinance(_global_config) return _global_enhanced_yf def set_config(**kwargs): """Set global configuration for enhanced yfinance""" global _global_config, _global_enhanced_yf for key, value in kwargs.items(): if hasattr(_global_config, key): setattr(_global_config, key, value) else: raise ValueError(f"Unknown configuration option: {key}") # Reset singleton so next Ticker() call picks up new config with _global_enhanced_yf_lock: _global_enhanced_yf = None def get_config() -> RequestConfig: """Get current global configuration""" return _global_config # Convenience functions with full yfinance API compatibility def Ticker(symbol: str, session=None, proxy=None) -> EnhancedTicker: """Create enhanced ticker (fully compatible with yfinance.Ticker). All calls share the module-level EnhancedYFinance singleton so the adaptive rate limiter and session pool are coordinated process-wide. """ enhanced_yf = _get_global_enhanced_yf() ticker = enhanced_yf.get_ticker(symbol) # Override session if caller explicitly provides one (rare compatibility path) if session is not None: with enhanced_yf._session_pool_lock: enhanced_yf._session_pool = [session] return ticker class EnhancedTickers: """Enhanced Tickers class with full yfinance compatibility""" def __init__(self, tickers, session=None, proxy=None): # Parse tickers like original yfinance if isinstance(tickers, str): tickers = tickers.replace(',', ' ').split() elif not isinstance(tickers, list): tickers = list(tickers) self.symbols = [ticker.upper() for ticker in tickers] self.tickers = {symbol: Ticker(symbol, session, proxy) for symbol in self.symbols} # Store original yfinance Tickers for method delegation self._yf_tickers = yf.Tickers(self.symbols, session=session) def __repr__(self): return f"yfinance_enhanced.Tickers object <{','.join(self.symbols)}>" def __getattr__(self, name): """Delegate any missing methods to original yfinance Tickers""" return getattr(self._yf_tickers, name) def download(self, *args, **kwargs): """Enhanced download for multiple tickers""" return download(self.symbols, *args, **kwargs) # Additional yfinance compatibility functions def Tickers(tickers, session=None, proxy=None) -> EnhancedTickers: """Create enhanced tickers (fully compatible with yfinance.Tickers)""" return EnhancedTickers(tickers, session, proxy) # Cache management functions def clear_cache(): """Clear all cached data""" cache = HistoricalDataCache(_global_config.cache_dir) cache.clear() def get_cache_info(): """Get cache information""" cache = HistoricalDataCache(_global_config.cache_dir) return cache.get_cache_info() # Make the module work exactly like yfinance from yfinance import __version__ # Export main classes and functions for full compatibility __all__ = [ 'Ticker', 'Tickers', 'download', 'set_config', 'get_config', 'clear_cache', 'get_cache_info', 'RequestConfig', 'EnhancedTicker', 'EnhancedTickers', 'EnhancedYFinance', 'HistoricalDataCache' ] # Example usage if __name__ == "__main__": # Setup logging logging.basicConfig(level=logging.INFO) # Create enhanced ticker ticker = Ticker("AAPL") try: # Test all yfinance functionality automatically print("Testing enhanced yfinance with automatic wrapping...") # All these should work automatically via __getattr__ print(f"Info: {ticker.info.get('longName', 'N/A')}") print(f"History: {len(ticker.history(period='5d'))} rows") print(f"Dividends: {len(ticker.dividends)} records") print(f"Splits: {len(ticker.splits)} records") print(f"Major holders: {ticker.major_holders.shape if hasattr(ticker.major_holders, 'shape') else 'Available'}") print(f"Recommendations: {ticker.recommendations.shape if hasattr(ticker.recommendations, 'shape') else 'Available'}") print(f"News: {len(ticker.news) if ticker.news else 0} articles") print("✅ All yfinance functionality works automatically!") except Exception as e: print(f"Error: {e}") import traceback traceback.print_exc()