""" Yahoo Finance Service Fetches real-time stock price data from Yahoo Finance """ import yfinance as yf import pandas as pd from datetime import datetime, timedelta from typing import Dict, Optional, List import pytz import time from utils.logger import logger class YahooFinanceService: """Service for fetching real-time stock price data from Yahoo Finance""" def __init__(self): self.ct_tz = pytz.timezone('America/Chicago') self._last_request_time = {} self._min_request_interval = 0.1 # 100ms between requests self._failed_symbols = set() self._cache = {} def _normalize_symbol(self, symbol: str) -> str: """ Normalize symbol for Yahoo Finance API Converts dots to hyphens (e.g., BRK.B -> BRK-B) """ if not symbol: return symbol # Convert dots to hyphens for Yahoo Finance API return symbol.upper().replace('.', '-') def get_current_price(self, symbol: str) -> Optional[float]: """Get current price for a symbol""" try: # Normalize symbol for Yahoo Finance normalized_symbol = self._normalize_symbol(symbol) ticker = yf.Ticker(normalized_symbol) data = ticker.history(period="1d", interval="1m") if not data.empty: return float(data['Close'].iloc[-1]) # Fallback to info info = ticker.info return info.get('regularMarketPrice') or info.get('previousClose') except Exception as e: logger.debug(f"Error fetching current price for {symbol}: {e}") return None def _rate_limit(self, symbol: str): """Rate limit requests to avoid overwhelming Yahoo Finance""" now = time.time() last_time = self._last_request_time.get(symbol, 0) elapsed = now - last_time if elapsed < self._min_request_interval: time.sleep(self._min_request_interval - elapsed) self._last_request_time[symbol] = time.time() def _get_ticker_safe(self, symbol: str): """Safely get ticker with error handling""" # Normalize symbol for Yahoo Finance normalized_symbol = self._normalize_symbol(symbol) if normalized_symbol in self._failed_symbols: return None try: self._rate_limit(normalized_symbol) ticker = yf.Ticker(normalized_symbol) # Try to get info to validate symbol, but don't fail if it errors # Sometimes ticker.info fails due to API issues but history() still works try: info = ticker.info if not info or len(info) < 2: # Valid ticker should have multiple info fields logger.warning(f"⚠️ Symbol {symbol} (normalized: {normalized_symbol}) appears invalid or delisted - info has {len(info) if info else 0} fields") # Don't add to failed symbols yet - try history() first else: logger.debug(f"✅ Validated ticker {symbol} (normalized: {normalized_symbol}) - {len(info)} info fields") except Exception as info_error: # ticker.info can fail due to API issues (JSON parsing errors, rate limits, etc.) # But history() might still work, so we'll try it anyway error_msg = str(info_error) if 'Expecting value' in error_msg or 'JSON' in error_msg: logger.warning(f"⚠️ Yahoo Finance API returned invalid JSON for {symbol} (normalized: {normalized_symbol}) - may be rate limited or API issue") logger.warning(f" Will attempt to fetch history data anyway (history() sometimes works when info() fails)") else: logger.debug(f"⚠️ Could not get ticker.info for {symbol} (normalized: {normalized_symbol}): {info_error}") # Return ticker even if info() failed - history() might still work return ticker except Exception as e: error_msg = str(e) if 'Expecting value' in error_msg or 'JSON' in error_msg: logger.warning(f"⚠️ Yahoo Finance API error (likely rate limiting or API issue) for {symbol} (normalized: {normalized_symbol})") logger.warning(f" Error: {type(e).__name__} - {error_msg}") # Don't add to failed symbols for API errors - might work on retry else: self._failed_symbols.add(normalized_symbol) logger.warning(f"⚠️ Error creating ticker for {symbol} (normalized: {normalized_symbol}): {type(e).__name__} - {error_msg}") return None def get_price_at_time( self, symbol: str, timestamp: datetime ) -> Optional[float]: """ Get price at or before a specific timestamp For historical data, fetches intraday data and finds closest match """ # Check cache first cache_key = f"{symbol}_{timestamp.isoformat()}" if cache_key in self._cache: return self._cache[cache_key] try: # Convert timestamp to aware datetime if needed if timestamp.tzinfo is None: timestamp = self.ct_tz.localize(timestamp) # Skip future dates - no data available now = datetime.now(self.ct_tz) if timestamp > now: logger.warning(f"⚠️ Future date for {symbol}: {timestamp} (now: {now}) - Yahoo Finance cannot provide future data") return None ticker = self._get_ticker_safe(symbol) if not ticker: logger.warning(f"⚠️ Could not get ticker for {symbol} - symbol may be invalid or delisted") return None # For current/recent timestamps, fetch intraday data time_diff = (now - timestamp).total_seconds() / 3600 # hours # Yahoo Finance only provides intraday data for the last 7 days # For older data, we can only get daily close prices if time_diff <= 168: # 7 days try: # Fetch 1-minute data for the day data = ticker.history( start=timestamp.date(), end=(timestamp + timedelta(days=1)).date(), interval="1m", timeout=5 # 5 second timeout ) if not data.empty: # Find closest timestamp before or equal to target data = data[data.index <= timestamp] if not data.empty: price = float(data['Close'].iloc[-1]) self._cache[cache_key] = price return price except Exception as e: logger.warning(f"⚠️ Intraday fetch failed for {symbol} at {timestamp}: {e}") logger.warning(f" Note: Yahoo Finance only provides intraday data for the last 7 days") logger.warning(f" Signal is {time_diff:.1f} hours old - trying daily data instead") # For older data or if intraday fails, use daily data # Note: Daily data doesn't give us exact price at a specific time, only the day's close try: data = ticker.history( start=timestamp.date() - timedelta(days=1), end=timestamp.date() + timedelta(days=1), interval="1d", timeout=5 ) if not data.empty: # Get closest date data = data[data.index.date <= timestamp.date()] if not data.empty: price = float(data['Close'].iloc[-1]) logger.info(f"✅ Got daily close price for {symbol} on {timestamp.date()}: ${price:.2f}") self._cache[cache_key] = price return price else: logger.warning(f"⚠️ No daily data found for {symbol} on or before {timestamp.date()}") else: logger.warning(f"⚠️ No daily data returned for {symbol} around {timestamp.date()}") except Exception as e: logger.warning(f"⚠️ Daily fetch failed for {symbol}: {e}") logger.warning(f"❌ Could not fetch price for {symbol} at {timestamp}") return None except Exception as e: logger.debug(f"Error fetching price at time for {symbol} at {timestamp}: {e}") return None def get_intraday_data( self, symbol: str, start_time: datetime, end_time: datetime ) -> pd.DataFrame: """ Get intraday 1-minute data for a symbol between start_time and end_time Returns DataFrame with columns: Open, High, Low, Close, Volume """ try: normalized_symbol = self._normalize_symbol(symbol) logger.debug(f"📊 Fetching intraday data for {symbol} (normalized: {normalized_symbol})") ticker = self._get_ticker_safe(symbol) if not ticker: logger.warning(f"⚠️ Could not get ticker for {symbol} (normalized: {normalized_symbol}) - cannot fetch intraday data") logger.warning(f" This symbol may be invalid, delisted, or Yahoo Finance may be rate-limiting") return pd.DataFrame() logger.debug(f"📊 Fetching intraday data for {symbol} from {start_time.strftime('%Y-%m-%d %H:%M:%S %Z')} to {end_time.strftime('%Y-%m-%d %H:%M:%S %Z')}") # Fetch 1-minute data with retry logic data = pd.DataFrame() max_retries = 3 for attempt in range(max_retries): try: logger.debug(f"📊 Attempt {attempt + 1}/{max_retries}: Fetching intraday data for {symbol} (normalized: {normalized_symbol})") data = ticker.history( start=start_time.date(), end=(end_time + timedelta(days=1)).date(), interval="1m", timeout=15 # 15 second timeout for intraday data ) if not data.empty: logger.debug(f"✅ Successfully fetched {len(data)} bars for {symbol}") break # Success, exit retry loop else: logger.warning(f"⚠️ Empty data returned (attempt {attempt + 1}/{max_retries}) for {symbol}") if attempt < max_retries - 1: time.sleep(2) # Wait 2 seconds before retry except Exception as fetch_error: error_msg = str(fetch_error) error_type = type(fetch_error).__name__ # Check for JSON parsing errors (API issues) if 'Expecting value' in error_msg or 'JSON' in error_msg or 'ValueError' in error_type: logger.warning(f"⚠️ Yahoo Finance API error (attempt {attempt + 1}/{max_retries}) for {symbol}: {error_msg}") if attempt < max_retries - 1: # Wait before retry (exponential backoff: 2s, 4s, 6s) wait_time = (attempt + 1) * 2 logger.info(f"⏳ Waiting {wait_time}s before retry (Yahoo Finance may be rate-limiting)...") time.sleep(wait_time) else: logger.error(f"❌ Failed to fetch intraday data after {max_retries} attempts for {symbol}") logger.error(f" Yahoo Finance API appears to be having issues or rate-limiting requests") else: logger.warning(f"⚠️ Error fetching intraday data for {symbol} (attempt {attempt + 1}/{max_retries}): {error_type} - {error_msg}") if attempt < max_retries - 1: time.sleep(1) # Short wait for other errors else: break # Don't retry further for non-API errors if data.empty: logger.warning(f"⚠️ No intraday data returned from Yahoo Finance for {symbol} on {start_time.date()}") logger.warning(f" This could be due to:") logger.warning(f" - Yahoo Finance API rate limiting or temporary issues") logger.warning(f" - Symbol {symbol} may need a different format (e.g., crypto symbols)") logger.warning(f" - Data is older than 7 days (Yahoo Finance limitation)") return pd.DataFrame() logger.debug(f"📊 Fetched {len(data)} bars from Yahoo Finance for {symbol}") # Filter to time range if data.index.tz is None: # Assume data is in market timezone (Eastern Time for US markets) # Yahoo Finance typically returns data in ET et_tz = pytz.timezone('America/New_York') data.index = data.index.tz_localize(et_tz) logger.debug(f"📊 Localized data index to ET (was naive)") # Convert start_time and end_time to same timezone as data if data.index.tz: start_time_tz = start_time.astimezone(data.index.tz) if start_time.tzinfo else data.index.tz.localize(start_time) end_time_tz = end_time.astimezone(data.index.tz) if end_time.tzinfo else data.index.tz.localize(end_time) else: start_time_tz = start_time end_time_tz = end_time logger.debug(f"📊 Filtering data: {start_time_tz.strftime('%H:%M:%S')} to {end_time_tz.strftime('%H:%M:%S')}") # Store original data range for logging original_data = data.copy() data = data[(data.index >= start_time_tz) & (data.index <= end_time_tz)] logger.debug(f"📊 After filtering: {len(data)} bars remain (from {len(original_data)} total bars)") if data.empty: logger.warning(f"⚠️ No data in time range {start_time_tz.strftime('%H:%M:%S')} to {end_time_tz.strftime('%H:%M:%S')} for {symbol}") if not original_data.empty: logger.warning(f" Available data range: {original_data.index.min().strftime('%H:%M:%S')} to {original_data.index.max().strftime('%H:%M:%S')}") else: logger.warning(f" No data available at all for {symbol} on {start_time.date()}") return data[['Open', 'High', 'Low', 'Close', 'Volume']] except Exception as e: logger.error(f"❌ Error fetching intraday data for {symbol}: {type(e).__name__} - {str(e)}") import traceback logger.debug(f"Traceback: {traceback.format_exc()}") return pd.DataFrame() def get_rth_open(self, symbol: str, date: datetime.date) -> Optional[float]: """Get RTH open price (9:30 AM CST) for a given date""" try: # Skip future dates today = datetime.now(self.ct_tz).date() if date > today: logger.debug(f"Skipping future date for RTH open: {symbol} on {date}") return None ticker = self._get_ticker_safe(symbol) if not ticker: return None # Fetch 1-minute data for the day data = ticker.history( start=date, end=date + timedelta(days=1), interval="1m", timeout=5 ) if data.empty: return None # Find first bar at or after 9:30 AM CST rth_start = self.ct_tz.localize( datetime.combine(date, datetime.min.time().replace(hour=9, minute=30)) ) if data.index.tz is None: data.index = data.index.tz_localize(self.ct_tz) # Filter to RTH hours rth_data = data[data.index >= rth_start] if not rth_data.empty: return float(rth_data['Open'].iloc[0]) return None except Exception as e: logger.debug(f"Error fetching RTH open for {symbol} on {date}: {e}") return None def get_prior_close(self, symbol: str, date: datetime.date) -> Optional[float]: """Get prior day's close price""" try: ticker = self._get_ticker_safe(symbol) if not ticker: return None # Fetch daily data prior_date = date - timedelta(days=1) data = ticker.history( start=prior_date - timedelta(days=5), # Get a few days to account for weekends end=date, interval="1d", timeout=5 ) if data.empty: return None # Get most recent close before the date data = data[data.index.date < date] if not data.empty: return float(data['Close'].iloc[-1]) return None except Exception as e: logger.debug(f"Error fetching prior close for {symbol} before {date}: {e}") return None async def calculate_vwap( self, symbol: str, end_time: datetime ) -> Optional[float]: """ Calculate VWAP from RTH open (9:30 AM ET) to end_time VWAP = Σ(Price × Volume) / Σ(Volume) US market opens at 9:30 AM Eastern Time Uses database (Bookmap) data first, falls back to Yahoo Finance """ try: # Use Eastern Time for market hours (US market is in ET) et_tz = pytz.timezone('America/New_York') # Ensure end_time is timezone-aware and convert to ET if end_time.tzinfo is None: # Assume UTC if naive end_time = pytz.UTC.localize(end_time) end_time = end_time.astimezone(et_tz) # Skip future dates now = datetime.now(et_tz) if end_time > now: logger.debug(f"Skipping future date for VWAP: {symbol} at {end_time}") return None # Get RTH open time for the day (9:30 AM ET) rth_start = et_tz.localize( datetime.combine(end_time.date(), datetime.min.time().replace(hour=9, minute=30)) ) logger.debug(f"📊 Calculating VWAP for {symbol} at {end_time.strftime('%Y-%m-%d %H:%M ET')}") logger.debug(f" RTH start = {rth_start.strftime('%Y-%m-%d %H:%M:%S %Z')}, End = {end_time.strftime('%Y-%m-%d %H:%M:%S %Z')}") # Fetch intraday data from RTH open to end_time # Convert to CST for get_intraday_data (Yahoo Finance data is typically in market timezone) cst_tz = pytz.timezone('America/Chicago') rth_start_cst = rth_start.astimezone(cst_tz) end_time_cst = end_time.astimezone(cst_tz) logger.debug(f"📊 Fetching intraday data: {rth_start_cst.strftime('%H:%M')} CST to {end_time_cst.strftime('%H:%M')} CST") data = await self.get_intraday_data(symbol, rth_start_cst, end_time_cst) if data.empty: logger.warning(f"⚠️ No intraday data available for VWAP calculation for {symbol} at {end_time.strftime('%Y-%m-%d %H:%M:%S %Z')}") logger.warning(f" RTH start: {rth_start.strftime('%H:%M:%S %Z')} (9:30 AM ET)") logger.warning(f" Checked: Database (prices_intraday_1m) and Yahoo Finance") logger.warning(f" Possible reasons:") logger.warning(f" - No data in database (Bookmap may not be sending data for this symbol)") logger.warning(f" - Data is older than 7 days (Yahoo Finance limitation)") logger.warning(f" - Symbol is invalid or delisted") logger.warning(f" - Market was closed at this time") logger.warning(f" - Yahoo Finance API rate limiting or timeout") return None # Calculate VWAP using standard formula (same as TradingView) # VWAP = Σ(Price × Volume) / Σ(Volume) # Where Price = Typical Price = (High + Low + Close) / 3 # This matches the industry-standard VWAP calculation # Calculate typical price for each bar: Pt = (Ht + Lt + Ct) / 3 data['TypicalPrice'] = (data['High'] + data['Low'] + data['Close']) / 3 # Calculate price × volume for each bar: PVt = Pt × Vt data['PriceVolume'] = data['TypicalPrice'] * data['Volume'] # Calculate cumulative sums from RTH open to end_time # Cumulative PV = Σ(Pt × Vt), Cumulative V = Σ(Vt) total_pv = data['PriceVolume'].sum() total_volume = data['Volume'].sum() # VWAP = Cumulative PV / Cumulative V if total_volume > 0: vwap = total_pv / total_volume logger.info(f"✅ VWAP calculated for {symbol}: ${vwap:.2f} (from {len(data)} bars, {total_volume:,.0f} total volume)") return float(vwap) else: logger.warning(f"⚠️ Zero volume for {symbol} - cannot calculate VWAP") return None except Exception as e: logger.debug(f"Error calculating VWAP for {symbol}: {e}") return None