From b88835cdeda19d597eb5e5b018227639ad963c92 Mon Sep 17 00:00:00 2001 From: I Luk Kim Date: Sat, 14 Mar 2026 16:32:33 -0700 Subject: [PATCH] feat(screener): add stock screener API with yfinance EquityQuery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/api/v1/api.py | 5 +- app/api/v1/endpoints/screener.py | 205 ++++++++++++++++++++++++ app/main.py | 20 +++ app/schemas/screener.py | 39 +++++ app/services/screener_service.py | 267 +++++++++++++++++++++++++++++++ 5 files changed, 534 insertions(+), 2 deletions(-) create mode 100644 app/api/v1/endpoints/screener.py create mode 100644 app/schemas/screener.py create mode 100644 app/services/screener_service.py 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 maps
  • +

    Stock Screener NEW

    + +

    Attention Overlay NEW