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.

219 lines
8.2 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""
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",
"min_dollar_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 in SHARES (e.g. 500000)"
),
min_dollar_volume: Optional[float] = Query(
None,
ge=0,
description="Opt-in. Minimum 3-month average daily DOLLAR volume "
"(regularMarketPrice × averageDailyVolume3Month), e.g. "
"100000000 for $100M/day. When set, the full match set is "
"fetched and post-filtered, and the share-count "
"min_avg_volume gate is NOT applied (it would exclude "
"high-priced low-share-volume names like BLK/KLAC at the "
"source). When omitted, screener behaviour is unchanged.",
),
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,
min_dollar_volume=min_dollar_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",
],
}