diff --git a/app/api/v1/api.py b/app/api/v1/api.py index a636112..3af1572 100644 --- a/app/api/v1/api.py +++ b/app/api/v1/api.py @@ -3,7 +3,7 @@ API v1 router """ from fastapi import APIRouter -from app.api.v1.endpoints import financial, price, catalog, health, migration, database, error_logs, request_logs, news, etf, stocks, fred, filings, alpaca, finra, overlay +from app.api.v1.endpoints import financial, price, catalog, health, migration, database, error_logs, request_logs, news, etf, stocks, fred, filings, alpaca, finra, overlay, screener api_router = APIRouter() @@ -24,4 +24,5 @@ api_router.include_router(error_logs.router, prefix="/admin/errors", tags=["erro api_router.include_router(request_logs.router, prefix="/admin/requests", tags=["request-logs"]) api_router.include_router(alpaca.router, prefix="/alpaca", tags=["alpaca"]) api_router.include_router(finra.router, prefix="/finra", tags=["finra"]) -api_router.include_router(overlay.router, prefix="/overlay", tags=["overlay"]) \ No newline at end of file +api_router.include_router(overlay.router, prefix="/overlay", tags=["overlay"]) +api_router.include_router(screener.router, prefix="/screener", tags=["screener"]) \ No newline at end of file diff --git a/app/api/v1/endpoints/screener.py b/app/api/v1/endpoints/screener.py new file mode 100644 index 0000000..a8a5b6b --- /dev/null +++ b/app/api/v1/endpoints/screener.py @@ -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", + ], + } diff --git a/app/main.py b/app/main.py index 265b942..922f29b 100644 --- a/app/main.py +++ b/app/main.py @@ -190,6 +190,12 @@ async def root_documentation():
POST /etf/admin/refresh-maps - Refresh CUSIP/CIK mapsGET /screener/stocks - Filter stocks by market cap, volume, price, P/E, sector, exchangeGET /screener/fields - Available filter options, sectors, sort fields (metadata)GET /overlay/{{symbol}} - Overlay score + features + source details (0~1 score, band, hints)# Small/mid-cap stocks on NYSE+NASDAQ with avg volume > 500K, sorted by market cap
+curl "http://localhost:18001/api/v1/screener/stocks?market_cap_min=500000000&market_cap_max=10000000000&exchange=NYSE,NASDAQ&min_avg_volume=500000&exclude_types=ETF,FUND"
+
+# Technology sector only
+curl "http://localhost:18001/api/v1/screener/stocks?market_cap_min=500000000&exchange=NASDAQ§or=Technology&page=1&page_size=50"
+
+# Available filter metadata
+curl "http://localhost:18001/api/v1/screener/fields"
+
# Overlay score for a single symbol
curl "http://localhost:18001/api/v1/overlay/AAPL"
@@ -430,6 +446,10 @@ app.openapi_tags = [
{
"name": "overlay-admin",
"description": "Overlay administrative endpoints (pipeline trigger, health, job log)"
+ },
+ {
+ "name": "screener",
+ "description": "Stock screener — condition-based filtering by market cap, volume, price, P/E, sector, exchange"
}
]
diff --git a/app/schemas/screener.py b/app/schemas/screener.py
new file mode 100644
index 0000000..87652e6
--- /dev/null
+++ b/app/schemas/screener.py
@@ -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]
diff --git a/app/services/screener_service.py b/app/services/screener_service.py
new file mode 100644
index 0000000..3b1d07b
--- /dev/null
+++ b/app/services/screener_service.py
@@ -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()