You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

759 lines
31 KiB
Python

#!/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()