""" 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", summary="Screen stocks by financial criteria") @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", summary="Available screener filter options and valid values") 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", ], }