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