diff --git a/yfinance_plus.py b/yfinance_plus.py new file mode 100644 index 0000000..3cefbe1 --- /dev/null +++ b/yfinance_plus.py @@ -0,0 +1,592 @@ +#!/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""" + 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(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""" + 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""" + with self._lock: + try: + 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) + + except Exception as e: + logging.warning(f"Cache store 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] + } + + +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 = None + self._lock = threading.Lock() + self._last_request_time = 0 + self._request_count = 0 + self._session_created_time = time.time() + + # 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.WARNING) # Reduce log verbosity + + # Suppress yfinance HTTP error logs + yf_logger = logging.getLogger('yfinance') + yf_logger.setLevel(logging.CRITICAL) + + def _create_enhanced_session(self) -> requests.Session: + """Create a session with browser-like headers and behavior""" + session = requests.Session(impersonate="chrome") + + # Enhanced headers to mimic real browser behavior + browser_headers = { + 'User-Agent': random.choice(self.config.user_agents), + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', + 'Accept-Language': 'en-US,en;q=0.9,ko;q=0.8,ja;q=0.7', + 'Accept-Encoding': 'gzip, deflate, br', + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache', + 'Sec-Ch-Ua': '"Not_A Brand";v="8", "Chromium";v="120", "Google Chrome";v="120"', + 'Sec-Ch-Ua-Mobile': '?0', + 'Sec-Ch-Ua-Platform': '"macOS"', + 'Sec-Fetch-Dest': 'document', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Site': 'cross-site', + 'Sec-Fetch-User': '?1', + 'Upgrade-Insecure-Requests': '1', + 'Connection': 'keep-alive', + 'DNT': '1', + 'Origin': 'https://finance.yahoo.com', + 'Referer': 'https://finance.yahoo.com/', + } + + session.headers.update(browser_headers) + + # Add realistic Yahoo Finance cookies + current_time = int(time.time()) + session.cookies.update({ + 'A1': f'd=AQABBC{random.randint(100000, 999999)}YLoCIgEBwQJ7vgAB&S=AQAAAg', + 'A1S': f'd=AQABBC{random.randint(100000, 999999)}YLoCIgEBwQJ7vgAB&S=AQAAAg', + 'A3': f'd=AQABBC{random.randint(100000, 999999)}YLoCIgEBwQJ7vgAB&S=AQAAAg', + 'GUC': f'AQEBAQFm{random.randint(1000, 9999)}k0L', + 'B': f'c={random.randint(1000000, 9999999)}&b=3&s=4u', + 'cmp': f't={current_time}&j=0&u=1---', + 'EuConsent': 'CP-r9cAP-r9cAAOACKENAoEgAAAAAAAAACiQAAAAAAAA', + }) + + return session + + @property + def session(self) -> requests.Session: + """Get or create enhanced session""" + with self._lock: + if self._session is None or self._should_refresh_session(): + self._session = self._create_enhanced_session() + self._session_created_time = time.time() + self._request_count = 0 + self.logger.debug("Created new enhanced session") + return self._session + + def _should_refresh_session(self) -> bool: + """Check if session should be refreshed""" + session_age = time.time() - self._session_created_time + return (session_age > 600 or # 10 minutes + self._request_count > 50) # 50 requests + + def _rate_limit_delay(self): + """Apply intelligent rate limiting""" + with self._lock: + current_time = time.time() + time_since_last = current_time - self._last_request_time + + # Minimum delay based on request frequency + min_delay = 0.1 if self._request_count < 10 else 0.2 + + if time_since_last < min_delay: + delay = min_delay - time_since_last + if self.config.jitter: + delay += random.uniform(0, delay * 0.5) + time.sleep(delay) + + self._last_request_time = time.time() + self._request_count += 1 + + def _retry_with_backoff(self, func, *args, **kwargs): + """Execute function with exponential backoff retry""" + last_exception = None + + for attempt in range(self.config.max_retries + 1): + try: + self._rate_limit_delay() + return func(*args, **kwargs) + + 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): + + 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, refreshing session and retrying in {delay:.2f}s (attempt {attempt + 1})") + else: + self.logger.warning(f"Rate limit hit, retrying in {delay:.2f}s (attempt {attempt + 1})") + + time.sleep(delay) + + # Refresh session on auth/rate limit error + with self._lock: + self._session = None + 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 + self._yf_ticker = None + + @property + def yf_ticker(self): + """Get yfinance ticker with enhanced session""" + if self._yf_ticker is None: + self._yf_ticker = yf.Ticker(self.symbol, session=self.enhanced_yf.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.debug(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) + + # 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""" + + config = RequestConfig( + max_retries=5, + base_delay=1.0, + max_delay=120.0 + ) + + enhanced_yf = EnhancedYFinance(config) + + 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() + +def set_config(**kwargs): + """Set global configuration for enhanced yfinance""" + global _global_config + for key, value in kwargs.items(): + if hasattr(_global_config, key): + setattr(_global_config, key, value) + else: + raise ValueError(f"Unknown configuration option: {key}") + +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)""" + config = RequestConfig( + max_retries=_global_config.max_retries, + base_delay=_global_config.base_delay, + max_delay=_global_config.max_delay, + jitter=_global_config.jitter, + user_agents=_global_config.user_agents, + enable_cache=_global_config.enable_cache, + cache_dir=_global_config.cache_dir + ) + + enhanced_yf = EnhancedYFinance(config) + + # Override session if provided (for compatibility) + if session is not None: + enhanced_yf._session = session + + return enhanced_yf.get_ticker(symbol) + +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() \ No newline at end of file diff --git a/yfinance_plus_cli.py b/yfinance_plus_cli.py new file mode 100644 index 0000000..0dfeb0b --- /dev/null +++ b/yfinance_plus_cli.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +""" +Command Line Interface for yfinance-enhanced +Provides cache management and utility functions +""" + +import argparse +import sys +import json +import pickle +from pathlib import Path +from datetime import datetime + +try: + from yfinance_plus import get_cache_info, clear_cache, HistoricalDataCache, RequestConfig +except ImportError: + print("Error: yfinance_plus module not found. Please install it first.") + sys.exit(1) + + +def format_size(size_bytes): + """Format size in bytes to human readable format""" + if size_bytes == 0: + return "0 B" + + for unit in ['B', 'KB', 'MB', 'GB']: + if size_bytes < 1024.0: + return f"{size_bytes:.1f} {unit}" + size_bytes /= 1024.0 + return f"{size_bytes:.1f} TB" + + +def get_cache_file_details(cache_dir, filename): + """Load and analyze cache file to get details about stored data""" + try: + cache_file = Path(cache_dir) / filename + if not cache_file.exists(): + return None + + with open(cache_file, 'rb') as f: + cache_data = pickle.load(f) + + # Extract details from cache data + details = { + 'symbol': cache_data.get('symbol', 'Unknown'), + 'period': cache_data.get('period', 'Unknown'), + 'interval': cache_data.get('interval', '1d'), + 'rows': len(cache_data.get('data', [])), + 'date_range': None + } + + # Try to get date range from the data + data = cache_data.get('data') + if data is not None and hasattr(data, 'index') and len(data) > 0: + try: + start_date = data.index[0].strftime('%Y-%m-%d') + end_date = data.index[-1].strftime('%Y-%m-%d') + details['date_range'] = f"{start_date} to {end_date}" + except: + pass + + return details + + except Exception as e: + # If we can't read the cache file, return minimal info + return { + 'symbol': 'Error', + 'period': f'({str(e)[:20]}...)', + 'interval': '?', + 'rows': 0, + 'date_range': None + } + + +def cmd_cache_info(args): + """Show cache information""" + try: + info = get_cache_info() + + print("šŸ—‚ļø YFinance Plus Cache Information") + print("=" * 50) + print(f"Cache Directory: {info['cache_dir']}") + print(f"Number of Files: {info['file_count']}") + print(f"Total Size: {format_size(info['total_size_mb'] * 1024 * 1024)}") + + if info['file_count'] > 0: + print(f"\nšŸ“ Cache Files:") + print("-" * 100) + + sorted_files = sorted(info['files'], key=lambda x: x['modified'], reverse=True) + + for file_info in sorted_files[:args.limit if args.limit else len(sorted_files)]: + size_str = format_size(file_info['size_kb'] * 1024) + modified_str = file_info['modified'].strftime('%Y-%m-%d %H:%M:%S') + + # Try to load and analyze cache file content + cache_details = get_cache_file_details(info['cache_dir'], file_info['name']) + + print(f" šŸ“„ {file_info['name'][:32]:<32} {size_str:>8} {modified_str}") + if cache_details: + print(f" Symbol: {cache_details['symbol']:<8} Period: {cache_details['period']:<6} " + f"Interval: {cache_details['interval']:<4} Rows: {cache_details['rows']}") + if cache_details['date_range']: + print(f" Date Range: {cache_details['date_range']}") + else: + print(f" āš ļø Could not read cache file details") + print() + + if args.limit and len(sorted_files) > args.limit: + print(f" ... and {len(sorted_files) - args.limit} more files") + else: + print("\nšŸ’” No cache files found") + + except Exception as e: + print(f"āŒ Error getting cache info: {e}") + sys.exit(1) + + +def cmd_cache_clear(args): + """Clear cache""" + if not args.force: + try: + info = get_cache_info() + if info['file_count'] > 0: + response = input(f"āš ļø This will delete {info['file_count']} cache files " + f"({format_size(info['total_size_mb'] * 1024 * 1024)}). " + f"Continue? [y/N]: ") + if response.lower() not in ['y', 'yes']: + print("āŒ Operation cancelled") + return + except Exception: + pass + + try: + clear_cache() + print("āœ… Cache cleared successfully") + except Exception as e: + print(f"āŒ Error clearing cache: {e}") + sys.exit(1) + + +def cmd_config_show(args): + """Show current configuration""" + config = RequestConfig() + + print("āš™ļø YFinance Plus Configuration") + print("=" * 40) + print(f"Max Retries: {config.max_retries}") + print(f"Base Delay: {config.base_delay}s") + print(f"Max Delay: {config.max_delay}s") + print(f"Jitter Enabled: {config.jitter}") + print(f"Cache Enabled: {config.enable_cache}") + print(f"Cache Directory: {config.cache_dir}") + print(f"User Agents: {len(config.user_agents)} configured") + + +def cmd_test_connection(args): + """Test connection to Yahoo Finance""" + print("šŸ”— Testing connection to Yahoo Finance...") + + try: + # Import here to avoid circular imports + from yfinance_plus import Ticker + + print(" Creating test ticker...") + ticker = Ticker("AAPL") + + print(" Fetching basic info...") + info = ticker.info + company_name = info.get('longName', 'Unknown') + + print(" Fetching historical data...") + hist = ticker.history(period="5d") + + print(f"āœ… Connection test successful!") + print(f" Company: {company_name}") + print(f" Historical data points: {len(hist)}") + + if ticker.enhanced_yf._cache: + cache_info = ticker.get_cache_info() + print(f" Cache status: {cache_info['file_count']} files") + + except Exception as e: + print(f"āŒ Connection test failed: {e}") + sys.exit(1) + + +def cmd_benchmark(args): + """Run performance benchmark""" + print("šŸƒ Running performance benchmark...") + + try: + import time + from yfinance_plus import Ticker, download + + symbols = ["AAPL", "GOOGL", "MSFT", "TSLA", "NVDA"] + + # Test individual tickers + print(f" Testing individual tickers ({len(symbols)} symbols)...") + start_time = time.time() + + for symbol in symbols: + ticker = Ticker(symbol) + hist = ticker.history(period="1mo") + print(f" {symbol}: {len(hist)} data points") + + individual_time = time.time() - start_time + + # Test bulk download + print(f" Testing bulk download...") + start_time = time.time() + + data = download(symbols, period="1mo") + bulk_time = time.time() - start_time + + print(f"\nšŸ“Š Benchmark Results:") + print(f" Individual requests: {individual_time:.2f}s") + print(f" Bulk download: {bulk_time:.2f}s") + print(f" Bulk speedup: {individual_time/bulk_time:.2f}x") + print(f" Data shape: {data.shape}") + + except Exception as e: + print(f"āŒ Benchmark failed: {e}") + sys.exit(1) + + +def main(): + """Main CLI entry point""" + parser = argparse.ArgumentParser( + description="YFinance Plus CLI - Cache management and utilities", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + yfp cache info # Show cache information + yfp cache clear # Clear cache (with confirmation) + yfp cache clear --force # Clear cache without confirmation + yfp config show # Show current configuration + yfp test # Test connection to Yahoo Finance + yfp benchmark # Run performance benchmark + """ + ) + + subparsers = parser.add_subparsers(dest='command', help='Available commands') + + # Cache commands + cache_parser = subparsers.add_parser('cache', help='Cache management commands') + cache_subparsers = cache_parser.add_subparsers(dest='cache_command') + + # Cache info + info_parser = cache_subparsers.add_parser('info', help='Show cache information') + info_parser.add_argument('--limit', type=int, help='Limit number of files to show') + info_parser.set_defaults(func=cmd_cache_info) + + # Cache clear + clear_parser = cache_subparsers.add_parser('clear', help='Clear cache') + clear_parser.add_argument('--force', action='store_true', help='Clear without confirmation') + clear_parser.set_defaults(func=cmd_cache_clear) + + # Config commands + config_parser = subparsers.add_parser('config', help='Configuration commands') + config_subparsers = config_parser.add_subparsers(dest='config_command') + + # Config show + show_parser = config_subparsers.add_parser('show', help='Show current configuration') + show_parser.set_defaults(func=cmd_config_show) + + # Test command + test_parser = subparsers.add_parser('test', help='Test connection to Yahoo Finance') + test_parser.set_defaults(func=cmd_test_connection) + + # Benchmark command + benchmark_parser = subparsers.add_parser('benchmark', help='Run performance benchmark') + benchmark_parser.set_defaults(func=cmd_benchmark) + + # Parse arguments + args = parser.parse_args() + + # Handle no command + if not args.command: + parser.print_help() + return + + # Handle cache subcommands + if args.command == 'cache' and not args.cache_command: + cache_parser.print_help() + return + + # Handle config subcommands + if args.command == 'config' and not args.config_command: + config_parser.print_help() + return + + # Execute command + if hasattr(args, 'func'): + try: + args.func(args) + except KeyboardInterrupt: + print("\nāŒ Operation cancelled by user") + sys.exit(1) + else: + parser.print_help() + + +if __name__ == "__main__": + main() \ No newline at end of file