feat(screener): add stock screener API with yfinance EquityQuery

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 <noreply@anthropic.com>
main
I Luk Kim 5 months ago
parent d82ba624e2
commit b88835cded

@ -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()
@ -25,3 +25,4 @@ api_router.include_router(request_logs.router, prefix="/admin/requests", tags=["
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"])
api_router.include_router(screener.router, prefix="/screener", tags=["screener"])

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

@ -190,6 +190,12 @@ async def root_documentation():
<li><code>POST /etf/admin/refresh-maps</code> - Refresh CUSIP/CIK maps</li>
</ul>
<h3>Stock Screener <span class="new-badge">NEW</span></h3>
<ul>
<li><code>GET /screener/stocks</code> - Filter stocks by market cap, volume, price, P/E, sector, exchange</li>
<li><code>GET /screener/fields</code> - Available filter options, sectors, sort fields (metadata)</li>
</ul>
<h3>Attention Overlay <span class="new-badge">NEW</span></h3>
<ul>
<li><code>GET /overlay/{{symbol}}</code> - Overlay score + features + source details (0~1 score, band, hints)</li>
@ -328,6 +334,16 @@ curl "http://localhost:18001/api/v1/finra/short-volume/AAPL?days=30"
# Get short ratio history
curl "http://localhost:18001/api/v1/finra/short-ratio/AAPL?days=60"</code></pre>
<h3>Stock Screener <span class="new-badge">NEW</span></h3>
<pre><code># Small/mid-cap stocks on NYSE+NASDAQ with avg volume &gt; 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&sector=Technology&page=1&page_size=50"
# Available filter metadata
curl "http://localhost:18001/api/v1/screener/fields"</code></pre>
<h3>Attention Overlay <span class="new-badge">NEW</span></h3>
<pre><code># 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"
}
]

@ -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]

@ -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()
Loading…
Cancel
Save