feat(screener): add stock screener API with yfinance EquityQuery
Implements GET /api/v1/screener/stocks and GET /api/v1/screener/fields for condition-based stock filtering without manual web searches. - app/schemas/screener.py: ScreenerStockItem + ScreenerResponse Pydantic models - app/services/screener_service.py: ScreenerService wrapping yfinance screen() via run_in_executor; exchange mapping (NYSE→NYQ, NASDAQ→NMS/NGM/NCM, etc.); btwn/gt/lt/is-in/eq EquityQuery builder; post-filter for ETF/FUND exclusion - app/api/v1/endpoints/screener.py: /stocks (with_cache TTL=300) + /fields metadata - app/api/v1/api.py: register screener router at prefix /screener - app/main.py: add screener OpenAPI tag and HTML doc section with examples Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>main
parent
d82ba624e2
commit
b88835cded
@ -0,0 +1,205 @@
|
||||
"""
|
||||
Stock Screener endpoints
|
||||
|
||||
Condition-based stock filtering via yfinance EquityQuery + screen().
|
||||
Results are cached in Redis for 5 minutes (TTL=300).
|
||||
"""
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Response
|
||||
import logging
|
||||
|
||||
from app.services.screener_service import screener_service
|
||||
from app.utils.cache import with_cache
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger("app.api.v1.screener")
|
||||
|
||||
|
||||
@router.get("/stocks")
|
||||
@with_cache(
|
||||
namespace="screener:stocks",
|
||||
ttl=300,
|
||||
key_params=[
|
||||
"market_cap_min", "market_cap_max", "exchange", "min_avg_volume",
|
||||
"exclude_types", "sector", "pe_min", "pe_max", "price_min", "price_max",
|
||||
"page", "page_size", "sort_by", "sort_ascending",
|
||||
],
|
||||
)
|
||||
async def screen_stocks(
|
||||
response: Response,
|
||||
market_cap_min: Optional[float] = Query(
|
||||
None, ge=0, description="Minimum market cap in USD (e.g. 500000000 for $500M)"
|
||||
),
|
||||
market_cap_max: Optional[float] = Query(
|
||||
None, ge=0, description="Maximum market cap in USD (e.g. 10000000000 for $10B)"
|
||||
),
|
||||
exchange: Optional[str] = Query(
|
||||
None,
|
||||
description="Comma-separated exchange names: NYSE, NASDAQ, AMEX, NYSE_ARCA. "
|
||||
"Omit for all US exchanges.",
|
||||
),
|
||||
min_avg_volume: Optional[int] = Query(
|
||||
None, ge=0, description="Minimum 3-month average daily volume (e.g. 500000)"
|
||||
),
|
||||
exclude_types: Optional[str] = Query(
|
||||
None,
|
||||
description="Comma-separated quote types to exclude (e.g. ETF,FUND). "
|
||||
"Only EQUITY results are kept when specified.",
|
||||
),
|
||||
sector: Optional[str] = Query(
|
||||
None,
|
||||
description="Filter by sector (e.g. Technology, Healthcare, 'Financial Services'). "
|
||||
"Note: sector is not returned per-stock in the response.",
|
||||
),
|
||||
pe_min: Optional[float] = Query(None, ge=0, description="Minimum trailing P/E ratio"),
|
||||
pe_max: Optional[float] = Query(None, ge=0, description="Maximum trailing P/E ratio"),
|
||||
price_min: Optional[float] = Query(None, ge=0, description="Minimum stock price in USD"),
|
||||
price_max: Optional[float] = Query(None, ge=0, description="Maximum stock price in USD"),
|
||||
page: int = Query(1, ge=1, description="Page number (1-based)"),
|
||||
page_size: int = Query(
|
||||
100, ge=1, le=250, description="Results per page (max 250, Yahoo API limit)"
|
||||
),
|
||||
sort_by: str = Query(
|
||||
"market_cap",
|
||||
description="Sort field: market_cap, volume, avg_volume, price, pe_ratio, "
|
||||
"change_percent, name, eps, dividend_yield, forward_pe, price_to_book",
|
||||
),
|
||||
sort_ascending: bool = Query(False, description="Sort ascending (default: descending)"),
|
||||
force_refresh: bool = Query(False, description="Bypass cache and fetch fresh data"),
|
||||
):
|
||||
"""
|
||||
Screen stocks based on financial criteria using yfinance.
|
||||
|
||||
Filters stocks from US exchanges (NYSE, NASDAQ, AMEX, NYSE_ARCA) by market cap,
|
||||
volume, price, P/E ratio, sector, and more. Results are paginated and cached for
|
||||
5 minutes.
|
||||
|
||||
**Exchange mapping**:
|
||||
- `NYSE` → NYQ
|
||||
- `NASDAQ` → NMS, NGM, NCM
|
||||
- `AMEX` → ASE
|
||||
- `NYSE_ARCA` → PCX
|
||||
|
||||
**Important limitations**:
|
||||
- `page_size` maximum is 250 (Yahoo Finance API limit)
|
||||
- `sector` filtering works but sector is NOT returned per-stock in the response
|
||||
- Results reflect real-time Yahoo Finance data
|
||||
|
||||
**Example**:
|
||||
```
|
||||
GET /screener/stocks?market_cap_min=500000000&market_cap_max=10000000000
|
||||
&exchange=NYSE,NASDAQ&min_avg_volume=500000&exclude_types=ETF,FUND
|
||||
&sort_by=market_cap&page=1&page_size=100
|
||||
```
|
||||
"""
|
||||
try:
|
||||
result = await screener_service.screen_stocks(
|
||||
market_cap_min=market_cap_min,
|
||||
market_cap_max=market_cap_max,
|
||||
exchange=exchange,
|
||||
min_avg_volume=min_avg_volume,
|
||||
exclude_types=exclude_types,
|
||||
sector=sector,
|
||||
pe_min=pe_min,
|
||||
pe_max=pe_max,
|
||||
price_min=price_min,
|
||||
price_max=price_max,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
sort_by=sort_by,
|
||||
sort_ascending=sort_ascending,
|
||||
)
|
||||
logger.info(
|
||||
"Screener returned %d stocks (total_available=%s, page=%d)",
|
||||
result["returned_count"],
|
||||
result["total_available"],
|
||||
result["page"],
|
||||
)
|
||||
return result
|
||||
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(status_code=503, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error("Screener error: %s", e, exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Screener query failed: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/fields")
|
||||
async def get_screener_fields():
|
||||
"""
|
||||
Return metadata about available screener filter options.
|
||||
|
||||
Useful for building dynamic filter UIs — lists all valid exchange names,
|
||||
sectors, sort fields, and parameter descriptions.
|
||||
"""
|
||||
return {
|
||||
"exchanges": {
|
||||
"values": ["NYSE", "NASDAQ", "AMEX", "NYSE_ARCA"],
|
||||
"description": "US stock exchange names (comma-separate multiple values)",
|
||||
"yfinance_codes": {
|
||||
"NYSE": ["NYQ"],
|
||||
"NASDAQ": ["NMS", "NGM", "NCM"],
|
||||
"AMEX": ["ASE"],
|
||||
"NYSE_ARCA": ["PCX"],
|
||||
},
|
||||
},
|
||||
"sectors": {
|
||||
"values": [
|
||||
"Technology",
|
||||
"Healthcare",
|
||||
"Financial Services",
|
||||
"Consumer Cyclical",
|
||||
"Industrials",
|
||||
"Consumer Defensive",
|
||||
"Energy",
|
||||
"Basic Materials",
|
||||
"Real Estate",
|
||||
"Utilities",
|
||||
"Communication Services",
|
||||
],
|
||||
"description": "Yahoo Finance sector names (exact match required). "
|
||||
"Note: sector is NOT returned per-stock in responses.",
|
||||
},
|
||||
"sort_fields": {
|
||||
"values": [
|
||||
"market_cap",
|
||||
"volume",
|
||||
"avg_volume",
|
||||
"price",
|
||||
"pe_ratio",
|
||||
"change_percent",
|
||||
"name",
|
||||
"eps",
|
||||
"dividend_yield",
|
||||
"forward_pe",
|
||||
"price_to_book",
|
||||
],
|
||||
"default": "market_cap",
|
||||
"description": "Fields available for sorting results",
|
||||
},
|
||||
"filters": {
|
||||
"market_cap_min": "Minimum market cap in USD",
|
||||
"market_cap_max": "Maximum market cap in USD",
|
||||
"exchange": "Comma-separated exchange names",
|
||||
"min_avg_volume": "Minimum 3-month average daily volume",
|
||||
"exclude_types": "Quote types to exclude (e.g. ETF,FUND)",
|
||||
"sector": "Yahoo Finance sector name (exact match)",
|
||||
"pe_min": "Minimum trailing P/E ratio",
|
||||
"pe_max": "Maximum trailing P/E ratio",
|
||||
"price_min": "Minimum stock price in USD",
|
||||
"price_max": "Maximum stock price in USD",
|
||||
},
|
||||
"pagination": {
|
||||
"page": "Page number, 1-based (default: 1)",
|
||||
"page_size": "Results per page, max 250 (default: 100)",
|
||||
},
|
||||
"limitations": [
|
||||
"page_size maximum is 250 (Yahoo Finance API limit)",
|
||||
"sector filter works but sector field is not returned per-stock",
|
||||
"Results reflect real-time Yahoo Finance data with 5-minute Redis cache",
|
||||
],
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
"""
|
||||
Stock Screener schemas - request/response models
|
||||
"""
|
||||
from typing import Optional, List, Any, Dict
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class ScreenerStockItem(BaseModel):
|
||||
symbol: str
|
||||
name: Optional[str] = None
|
||||
exchange: Optional[str] = None # User-friendly: NYSE, NASDAQ, etc.
|
||||
exchange_code: Optional[str] = None # Raw yfinance code: NYQ, NMS, etc.
|
||||
quote_type: Optional[str] = None
|
||||
market_cap: Optional[float] = None
|
||||
price: Optional[float] = None
|
||||
change_percent: Optional[float] = None
|
||||
volume: Optional[int] = None
|
||||
avg_volume_3m: Optional[int] = None
|
||||
shares_outstanding: Optional[float] = None
|
||||
pe_ratio: Optional[float] = None
|
||||
forward_pe: Optional[float] = None
|
||||
eps_ttm: Optional[float] = None
|
||||
dividend_yield: Optional[float] = None
|
||||
fifty_two_week_high: Optional[float] = None
|
||||
fifty_two_week_low: Optional[float] = None
|
||||
analyst_rating: Optional[str] = None
|
||||
book_value: Optional[float] = None
|
||||
price_to_book: Optional[float] = None
|
||||
|
||||
|
||||
class ScreenerResponse(BaseModel):
|
||||
stocks: List[ScreenerStockItem]
|
||||
total_available: int
|
||||
returned_count: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
query_time_seconds: float
|
||||
metadata: Dict[str, Any]
|
||||
@ -0,0 +1,267 @@
|
||||
"""
|
||||
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()
|
||||
Loading…
Reference in New Issue