""" Stock Screener Service Uses yfinance EquityQuery + screen() for real-time stock filtering. Results are passed through directly (no DB storage) with Redis caching at the API layer. """ import time import asyncio import logging from typing import Optional logger = logging.getLogger(__name__) class ScreenerService: # Maps user-facing exchange names to yfinance exchange codes EXCHANGE_MAP = { "NYSE": ["NYQ"], "NASDAQ": ["NMS", "NGM", "NCM"], "AMEX": ["ASE"], "NYSE_ARCA": ["PCX"], } # Maps raw yfinance exchange codes back to friendly names REVERSE_EXCHANGE_MAP = { "NYQ": "NYSE", "NMS": "NASDAQ", "NGM": "NASDAQ", "NCM": "NASDAQ", "ASE": "AMEX", "PCX": "NYSE_ARCA", } # Maps API sort_by parameter names to yfinance sort field names SORT_FIELD_MAP = { "market_cap": "intradaymarketcap", "volume": "dayvolume", "avg_volume": "avgdailyvol3m", "price": "intradayprice", "pe_ratio": "peratio.lasttwelvemonths", "change_percent": "percentchange", "name": "companyshortname", "eps": "epstrailingtwelvemonths", "dividend_yield": "trailingannualdividendyield", "forward_pe": "forwardpricetoearnings", "price_to_book": "pricebook", } def _build_query( self, market_cap_min: Optional[float], market_cap_max: Optional[float], exchange: Optional[str], min_avg_volume: Optional[int], sector: Optional[str], pe_min: Optional[float], pe_max: Optional[float], price_min: Optional[float], price_max: Optional[float], ): """Build EquityQuery from filter parameters.""" try: from yfinance import EquityQuery except ImportError: raise RuntimeError("yfinance is not installed") conditions = [] # Market cap filter if market_cap_min is not None and market_cap_max is not None: conditions.append(EquityQuery('btwn', ['intradaymarketcap', market_cap_min, market_cap_max])) elif market_cap_min is not None: conditions.append(EquityQuery('gt', ['intradaymarketcap', market_cap_min])) elif market_cap_max is not None: conditions.append(EquityQuery('lt', ['intradaymarketcap', market_cap_max])) # Exchange filter if exchange: exchange_codes = [] for ex in exchange.split(','): ex = ex.strip().upper() codes = self.EXCHANGE_MAP.get(ex, [ex]) exchange_codes.extend(codes) if exchange_codes: # is-in syntax: field name + values all in one list (not nested) conditions.append(EquityQuery('is-in', ['exchange', *exchange_codes])) else: # Default to US market when no exchange specified conditions.append(EquityQuery('eq', ['region', 'us'])) # Average volume filter if min_avg_volume is not None: conditions.append(EquityQuery('gt', ['avgdailyvol3m', min_avg_volume])) # Sector filter (filtering works, but sector won't appear in response per Yahoo API limits) if sector: conditions.append(EquityQuery('eq', ['sector', sector])) # PE ratio filter if pe_min is not None and pe_max is not None: conditions.append(EquityQuery('btwn', ['peratio.lasttwelvemonths', pe_min, pe_max])) elif pe_min is not None: conditions.append(EquityQuery('gt', ['peratio.lasttwelvemonths', pe_min])) elif pe_max is not None: conditions.append(EquityQuery('lt', ['peratio.lasttwelvemonths', pe_max])) # Price filter if price_min is not None and price_max is not None: conditions.append(EquityQuery('btwn', ['intradayprice', price_min, price_max])) elif price_min is not None: conditions.append(EquityQuery('gt', ['intradayprice', price_min])) elif price_max is not None: conditions.append(EquityQuery('lt', ['intradayprice', price_max])) if not conditions: return EquityQuery('eq', ['region', 'us']) if len(conditions) == 1: return conditions[0] return EquityQuery('and', conditions) def _parse_quote(self, quote: dict) -> dict: """Parse a raw yfinance quote dict into our response format.""" exchange_code = quote.get('exchange', '') exchange_friendly = self.REVERSE_EXCHANGE_MAP.get(exchange_code, exchange_code) # Volume fields may be float from yfinance; cast to int if present volume = quote.get('regularMarketVolume') avg_volume_3m = quote.get('averageDailyVolume3Month') return { 'symbol': quote.get('symbol', ''), 'name': quote.get('shortName') or quote.get('longName'), 'exchange': exchange_friendly, 'exchange_code': exchange_code, 'quote_type': quote.get('quoteType'), 'market_cap': quote.get('marketCap'), 'price': quote.get('regularMarketPrice'), 'change_percent': quote.get('regularMarketChangePercent'), 'volume': int(volume) if volume is not None else None, 'avg_volume_3m': int(avg_volume_3m) if avg_volume_3m is not None else None, 'shares_outstanding': quote.get('sharesOutstanding'), 'pe_ratio': quote.get('trailingPE'), 'forward_pe': quote.get('forwardPE'), 'eps_ttm': quote.get('epsTrailingTwelveMonths'), 'dividend_yield': quote.get('trailingAnnualDividendYield'), 'fifty_two_week_high': quote.get('fiftyTwoWeekHigh'), 'fifty_two_week_low': quote.get('fiftyTwoWeekLow'), 'analyst_rating': quote.get('averageAnalystRating'), 'book_value': quote.get('bookValue'), 'price_to_book': quote.get('priceToBook'), } def _screen_sync(self, query, offset: int, size: int, sort_field: str, sort_asc: bool) -> dict: """Synchronous yfinance screen() call — must run in executor.""" import yfinance as yf max_retries = 3 for attempt in range(max_retries): try: return yf.screen(query, offset=offset, size=size, sortField=sort_field, sortAsc=sort_asc) except Exception as e: err = str(e).lower() is_retriable = ( "too many requests" in err or "rate limit" in err or "429" in err or "401" in err or "unauthorized" in err ) if is_retriable and attempt < max_retries - 1: delay = (attempt + 1) * 3 # 3s, 6s logger.warning( "yfinance screen() error '%s' (attempt %d/%d), retrying in %.1fs", str(e)[:80], attempt + 1, max_retries, delay, ) time.sleep(delay) continue if is_retriable: raise RuntimeError( "Yahoo Finance is rate limiting this server. " "Please try again in 30–60 seconds." ) raise async def screen_stocks( self, market_cap_min: Optional[float] = None, market_cap_max: Optional[float] = None, exchange: Optional[str] = None, min_avg_volume: Optional[int] = None, exclude_types: Optional[str] = None, sector: Optional[str] = None, pe_min: Optional[float] = None, pe_max: Optional[float] = None, price_min: Optional[float] = None, price_max: Optional[float] = None, page: int = 1, page_size: int = 100, sort_by: str = "market_cap", sort_ascending: bool = False, ) -> dict: """Screen stocks with the given filters and return paginated results.""" start_time = time.time() page_size = max(1, min(page_size, 250)) page = max(1, page) query = self._build_query( market_cap_min=market_cap_min, market_cap_max=market_cap_max, exchange=exchange, min_avg_volume=min_avg_volume, sector=sector, pe_min=pe_min, pe_max=pe_max, price_min=price_min, price_max=price_max, ) sort_field = self.SORT_FIELD_MAP.get(sort_by, "intradaymarketcap") offset = (page - 1) * page_size loop = asyncio.get_event_loop() raw = await loop.run_in_executor( None, self._screen_sync, query, offset, page_size, sort_field, sort_ascending, ) quotes = raw.get('quotes', []) # yfinance may return total count under 'count' or 'total' total_available = raw.get('count') or raw.get('total') or len(quotes) # Post-filter: remove non-equity types if requested exclude_type_set = set() if exclude_types: exclude_type_set = {t.strip().upper() for t in exclude_types.split(',')} stocks = [] for quote in quotes: if exclude_type_set: qt = (quote.get('quoteType') or '').upper() if qt in exclude_type_set: continue stocks.append(self._parse_quote(quote)) query_time = time.time() - start_time total_pages = max(1, (total_available + page_size - 1) // page_size) filters_applied = {} if market_cap_min is not None: filters_applied['market_cap_min'] = market_cap_min if market_cap_max is not None: filters_applied['market_cap_max'] = market_cap_max if exchange: filters_applied['exchange'] = exchange if min_avg_volume is not None: filters_applied['min_avg_volume'] = min_avg_volume if exclude_types: filters_applied['exclude_types'] = exclude_types if sector: filters_applied['sector'] = sector if pe_min is not None: filters_applied['pe_min'] = pe_min if pe_max is not None: filters_applied['pe_max'] = pe_max if price_min is not None: filters_applied['price_min'] = price_min if price_max is not None: filters_applied['price_max'] = price_max return { 'stocks': stocks, 'total_available': total_available, 'returned_count': len(stocks), 'page': page, 'page_size': page_size, 'total_pages': total_pages, 'query_time_seconds': round(query_time, 3), 'metadata': { 'filters_applied': filters_applied, 'sort_by': sort_by, 'sort_ascending': sort_ascending, 'source': 'yfinance_screen', 'note': 'sector/industry not included in per-stock response (Yahoo API limitation)', }, } screener_service = ScreenerService()