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.

268 lines
10 KiB
Python

"""
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
return yf.screen(query, offset=offset, size=size, sortField=sort_field, sortAsc=sort_asc)
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()