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.

295 lines
11 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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