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.

206 lines
7.5 KiB
Python

"""
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",
],
}