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
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() |