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
parent
d82ba624e2
commit
b88835cded
@ -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",
|
||||||
|
],
|
||||||
|
}
|
||||||
@ -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…
Reference in New Issue