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.
626 lines
26 KiB
Python
626 lines
26 KiB
Python
"""
|
|
Stock Market Data endpoints
|
|
주식 시장 데이터 관련 API 엔드포인트
|
|
"""
|
|
|
|
from typing import Optional
|
|
from fastapi import APIRouter, HTTPException, Query, Response
|
|
import logging
|
|
import asyncio
|
|
from datetime import datetime
|
|
|
|
from app.services.yahoo_most_active_service import yahoo_most_active_service
|
|
from app.services.yahoo_52week_gainers_service import yahoo_52week_gainers_service
|
|
from app.services.index_constituents_service import index_constituents_service
|
|
from app.utils.cache import with_cache
|
|
|
|
router = APIRouter()
|
|
logger = logging.getLogger("app.api.v1.stocks")
|
|
|
|
|
|
@router.get(
|
|
"/index/{index_name}",
|
|
summary="Get index constituents (S&P 500 / Nasdaq 100)",
|
|
response_description="List of constituent stocks with symbol, name, sector, and industry",
|
|
)
|
|
@with_cache(namespace="stocks:index", ttl=86400, key_params=["index_name"])
|
|
async def get_index_constituents(
|
|
index_name: str,
|
|
response: Response,
|
|
force_refresh: bool = Query(False, description="If true, bypasses cache and fetches fresh data"),
|
|
):
|
|
"""
|
|
Get current constituents of a major stock index from Wikipedia.
|
|
|
|
Returns each stock's symbol, company name, GICS Sector, and GICS Sub-Industry.
|
|
|
|
**Supported values for `index_name`**:
|
|
- `sp500` — S&P 500 (~503 stocks)
|
|
- `nasdaq100` — Nasdaq 100 (~101 stocks)
|
|
|
|
**Data Source**: Wikipedia
|
|
**Cache TTL**: 24 hours (`X-Cache: HIT/MISS`, `ETag` headers included)
|
|
**Timeout**: 30 seconds (Wikipedia fetch)
|
|
|
|
**Error codes**:
|
|
- `400` — unsupported `index_name`
|
|
- `504` — Wikipedia response timed out
|
|
"""
|
|
supported = list(index_constituents_service.INDEXES.keys())
|
|
if index_name not in supported:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Unknown index '{index_name}'. Supported values: {supported}",
|
|
)
|
|
|
|
try:
|
|
result = await asyncio.wait_for(
|
|
index_constituents_service.get_constituents(index_name),
|
|
timeout=30,
|
|
)
|
|
|
|
if not result["success"]:
|
|
logger.error(f"❌ Index constituents service error for {index_name}: {result.get('error')}")
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail=f"Failed to fetch {index_name} constituents: {result.get('error', 'Unknown error')}",
|
|
)
|
|
|
|
logger.info(f"✅ Returned {result['count']} constituents for {index_name}")
|
|
return result
|
|
|
|
except HTTPException:
|
|
raise
|
|
except asyncio.TimeoutError:
|
|
logger.error(f"❌ Timeout fetching {index_name} constituents from Wikipedia")
|
|
raise HTTPException(
|
|
status_code=504,
|
|
detail=f"Timed out fetching {index_name} constituents from Wikipedia",
|
|
)
|
|
except Exception as e:
|
|
logger.error(f"❌ Unexpected error in get_index_constituents ({index_name}): {e}")
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Internal server error while fetching {index_name} constituents: {str(e)}",
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/most-active",
|
|
summary="Most actively traded stocks by volume",
|
|
description=(
|
|
"Get most actively traded stocks from Yahoo Finance.\n\n"
|
|
"**⚠️ 실시간 전용**: DB에 저장되지 않음. 과거 데이터 조회 불가.\n"
|
|
"캐시 TTL: 1시간 (`X-Cache: HIT/MISS` 헤더 포함)."
|
|
),
|
|
)
|
|
@with_cache(namespace="stocks:most-active", ttl=3600, key_params=["limit"])
|
|
async def get_most_active_stocks(
|
|
response: Response,
|
|
limit: Optional[int] = Query(None, ge=1, le=500, description="Maximum number of stocks to return (1-500). If not specified, returns all available stocks."),
|
|
force_refresh: bool = Query(False, description="If true, bypasses cache and fetches fresh data")
|
|
):
|
|
"""
|
|
Get most actively traded stocks from Yahoo Finance
|
|
|
|
Returns real-time data of the most actively traded stocks including:
|
|
- Stock symbol and company name
|
|
- Current price information
|
|
- Price change and percentage change
|
|
- Trading volume data
|
|
- Average volume data
|
|
|
|
**Data Source**: finance.yahoo.com/markets/stocks/most-active/
|
|
**Update Frequency**: Real-time (scraped on demand)
|
|
**Rate Limiting**: Uses curl_cffi with Chrome impersonation to bypass rate limits
|
|
|
|
**Example Response**:
|
|
```json
|
|
{
|
|
"success": true,
|
|
"data": {
|
|
"stocks": [
|
|
{
|
|
"symbol": "NVDA",
|
|
"company_name": "NVIDIA Corporation",
|
|
"price_raw": "$181.96",
|
|
"change_raw": "+0.42",
|
|
"change_percent_raw": "+0.23%",
|
|
"volume_raw": "45.2M",
|
|
"avg_volume_raw": "42.1M",
|
|
"scraped_at": "2025-01-14T10:30:00"
|
|
}
|
|
],
|
|
"total_available": 168,
|
|
"returned_count": 100,
|
|
"pages_fetched": 1,
|
|
"scraped_at": "2025-01-14T10:30:00"
|
|
},
|
|
"metadata": {
|
|
"source": "finance.yahoo.com",
|
|
"endpoint": "markets/stocks/most-active",
|
|
"method": "web_scraping",
|
|
"rate_limit_bypass": "curl_cffi_chrome_impersonation"
|
|
}
|
|
}
|
|
```
|
|
|
|
**Parameters**:
|
|
- `limit`: Number of stocks to return (optional). If not specified, returns all available stocks (~170)
|
|
|
|
**Notes**:
|
|
- Data is scraped in real-time from Yahoo Finance
|
|
- Without `limit`: Returns all available stocks (typically ~170)
|
|
- With `limit`: Returns top N most active stocks
|
|
- Uses advanced rate limiting bypass techniques (curl_cffi + Chrome impersonation)
|
|
"""
|
|
try:
|
|
# Fetch fresh data
|
|
if limit is None:
|
|
logger.info("📊 Getting ALL most active stocks (no limit specified)")
|
|
result = await yahoo_most_active_service.get_all_most_active_stocks()
|
|
else:
|
|
logger.info(f"📊 Getting most active stocks (limit={limit})")
|
|
result = await yahoo_most_active_service.get_most_active_stocks(limit=limit)
|
|
|
|
if not result['success']:
|
|
logger.error(f"❌ Yahoo Finance service error: {result.get('error')}")
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail=f"Failed to fetch most active stocks: {result.get('error', 'Unknown error')}"
|
|
)
|
|
|
|
stocks_data = result['data']
|
|
count_msg = f"all {stocks_data['returned_count']}" if limit is None else f"{stocks_data['returned_count']}"
|
|
logger.info(f"✅ Successfully returned {count_msg} most active stocks")
|
|
|
|
response_body = {
|
|
"success": True,
|
|
"message": f"Retrieved {count_msg} most active stocks",
|
|
**result
|
|
}
|
|
|
|
return response_body
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"❌ Unexpected error in get_most_active_stocks: {e}")
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Internal server error while fetching most active stocks: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/52-week-gainers",
|
|
summary="Top 52-week gaining stocks",
|
|
description=(
|
|
"Get 52-week top gaining stocks from Yahoo Finance.\n\n"
|
|
"**⚠️ 실시간 전용**: DB에 저장되지 않음. 과거 데이터 조회 불가.\n"
|
|
"캐시 TTL: 1시간. 첫 호출 시 15-30초 소요 (웹 스크래핑)."
|
|
),
|
|
)
|
|
@with_cache(namespace="stocks:52-week-gainers", ttl=3600, key_params=["limit", "max_pages"])
|
|
async def get_52week_gainers(
|
|
response: Response,
|
|
limit: Optional[int] = Query(None, ge=1, le=1000, description="Maximum number of stocks to return (1-1000). If not specified, returns first 600 stocks (3 pages) for performance."),
|
|
max_pages: Optional[int] = Query(3, ge=1, le=10, description="Maximum pages to fetch (1-10). Each page has ~200 stocks. Higher values may cause rate limiting."),
|
|
force_refresh: bool = Query(False, description="Bypass cache"),
|
|
):
|
|
"""
|
|
Get 52-week top gaining stocks from Yahoo Finance
|
|
|
|
Returns stocks with highest 52-week price gains including:
|
|
- Stock symbol and company name
|
|
- Current price and 52-week high
|
|
- Price change amount and percentage
|
|
- Trading volume data
|
|
- Gain percentages over 52-week period
|
|
|
|
**Data Source**: finance.yahoo.com/markets/stocks/52-week-gainers/
|
|
**Total Available**: ~1,350 stocks across 7 pages
|
|
**Update Frequency**: Real-time (scraped on demand)
|
|
**Rate Limiting**: Intelligent delays between requests to avoid blocking
|
|
|
|
**Example Response**:
|
|
```json
|
|
{
|
|
"success": true,
|
|
"data": {
|
|
"stocks": [
|
|
{
|
|
"symbol": "EXAMPLE",
|
|
"company_name": "Example Corp",
|
|
"current_price": "10.50",
|
|
"change_percent": "+150.00%",
|
|
"high_52w": "11.00",
|
|
"volume": "1.2M"
|
|
}
|
|
],
|
|
"total_available": 1350,
|
|
"returned_count": 200,
|
|
"elapsed_time_seconds": 15.2
|
|
}
|
|
}
|
|
```
|
|
|
|
**Parameters**:
|
|
- `limit`: Number of stocks to return (optional). Default: returns ~600 stocks (3 pages)
|
|
- `max_pages`: Maximum pages to scrape (1-10). Higher values take longer and may hit rate limits
|
|
|
|
**Performance Notes**:
|
|
- Default (3 pages): ~15-30 seconds, 600 stocks
|
|
- All pages (7 pages): ~45-90 seconds, 1,350 stocks
|
|
- Intelligent rate limiting with progressive delays
|
|
- Session management to avoid detection
|
|
- Automatic retry logic for failed requests
|
|
|
|
**Rate Limiting Strategy**:
|
|
- 1-3 second delays between requests
|
|
- 5+ second delays every 3 requests
|
|
- Progressive delays for later pages
|
|
- Session rotation every 5 minutes
|
|
"""
|
|
try:
|
|
if limit is None:
|
|
logger.info(f"📊 Getting 52-week gainers (default: {max_pages} pages)")
|
|
result = await yahoo_52week_gainers_service.get_52week_gainers(limit=None, max_pages=max_pages)
|
|
else:
|
|
logger.info(f"📊 Getting 52-week gainers (limit={limit}, max_pages={max_pages})")
|
|
result = await yahoo_52week_gainers_service.get_52week_gainers(limit=limit, max_pages=max_pages)
|
|
|
|
if not result['success']:
|
|
logger.error(f"❌ Yahoo Finance 52-week gainers error: {result.get('error')}")
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail=f"Failed to fetch 52-week gainers: {result.get('error', 'Unknown error')}"
|
|
)
|
|
|
|
stocks_data = result['data']
|
|
count_msg = f"all {stocks_data['returned_count']}" if limit is None else f"{stocks_data['returned_count']}"
|
|
elapsed = stocks_data.get('elapsed_time_seconds', 0)
|
|
|
|
logger.info(f"✅ Successfully returned {count_msg} 52-week gainers in {elapsed}s")
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Retrieved {count_msg} 52-week gaining stocks in {elapsed}s",
|
|
**result
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"❌ Unexpected error in get_52week_gainers: {e}")
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Internal server error while fetching 52-week gainers: {str(e)}"
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/gainers",
|
|
summary="Today's top gaining stocks (Yahoo Finance day_gainers)",
|
|
)
|
|
async def get_day_gainers(
|
|
count: int = Query(100, ge=1, le=250, description="Number of results to return (max 250)"),
|
|
):
|
|
"""
|
|
Top gaining stocks for today via Yahoo Finance's `day_gainers` predefined screener.
|
|
|
|
Criteria: price change > 3%, market cap >= $2B, price >= $5, volume > 15,000.
|
|
Sorted by percent change descending. Real-time — no cache.
|
|
|
|
Data source: `query1.finance.yahoo.com/v1/finance/screener/predefined/saved`
|
|
with browser fingerprint rotation for 429 bypass.
|
|
"""
|
|
import asyncio
|
|
import time as _time
|
|
from app.services.gainers.yahoo_client import fetch_day_gainers_sync, _parse_quote
|
|
|
|
start_ts = _time.time()
|
|
loop = asyncio.get_event_loop()
|
|
try:
|
|
raw_quotes, total, error = await loop.run_in_executor(
|
|
None, fetch_day_gainers_sync, count, 0
|
|
)
|
|
except Exception as e:
|
|
logger.error("Day gainers fetch error: %s", e, exc_info=True)
|
|
raise HTTPException(status_code=503, detail=str(e))
|
|
|
|
if error:
|
|
raise HTTPException(status_code=503, detail=f"Yahoo Finance error: {error}")
|
|
|
|
stocks = [_parse_quote(q) for q in raw_quotes]
|
|
return {
|
|
"stocks": stocks,
|
|
"total_available": total,
|
|
"returned_count": len(stocks),
|
|
"query_time_seconds": round(_time.time() - start_ts, 3),
|
|
"metadata": {
|
|
"source": "yahoo_finance_predefined_screener",
|
|
"preset": "day_gainers",
|
|
"criteria": "change>3%, mktcap>=$2B, price>=$5, volume>15k",
|
|
},
|
|
}
|
|
|
|
|
|
@router.get(
|
|
"/trending",
|
|
summary="Trending stocks combining most active and 52-week gainers",
|
|
description=(
|
|
"Get trending stocks by combining most-active + 52-week gainers.\n\n"
|
|
"**⚠️ 실시간 전용**: DB에 저장되지 않음. 과거 데이터 조회 불가.\n"
|
|
"캐시 TTL: 30분. 병렬 스크래핑으로 최적화."
|
|
),
|
|
)
|
|
@with_cache(namespace="stocks:trending", ttl=1800, key_params=["n", "most_active_limit", "gainers_limit"])
|
|
async def get_trending_stocks(
|
|
response: Response,
|
|
n: Optional[int] = Query(500, ge=1, description="Total number of trending stocks to return after combining most active + gainers (default: 500)"),
|
|
most_active_limit: Optional[int] = Query(None, ge=1, description="Number of most active stocks to include. If not specified, returns all available stocks (~170)."),
|
|
gainers_limit: Optional[int] = Query(None, ge=1, description="Number of 52-week gainers to fetch. If not specified, fetches enough to reach target 'n' after combining with most active."),
|
|
force_refresh: bool = Query(False, description="Bypass cache"),
|
|
):
|
|
"""
|
|
Get trending stocks combining most active and 52-week gainers
|
|
|
|
Returns a comprehensive list of trending stocks by combining:
|
|
- Most actively traded stocks (high volume, immediate market interest)
|
|
- Top 52-week gainers (strong long-term performance)
|
|
|
|
**Data Sources**:
|
|
- Most Active: finance.yahoo.com/markets/stocks/most-active/
|
|
- 52-Week Gainers: finance.yahoo.com/markets/stocks/52-week-gainers/
|
|
|
|
**Update Frequency**: Real-time (scraped on demand)
|
|
**Rate Limiting**: Optimized parallel fetching with intelligent delays
|
|
|
|
**Example Response**:
|
|
```json
|
|
{
|
|
"success": true,
|
|
"message": "Retrieved 500 trending stocks (170 most active + 330 gainers) in 18.5s",
|
|
"data": {
|
|
"trending_stocks": [
|
|
{
|
|
"symbol": "NVDA",
|
|
"company_name": "NVIDIA Corporation",
|
|
"current_price": "181.96",
|
|
"change_amount": "+0.42",
|
|
"change_percent": "+0.23%",
|
|
"volume": "45.2M",
|
|
"category": "most_active",
|
|
"rank_in_category": 1
|
|
},
|
|
{
|
|
"symbol": "TSLA",
|
|
"company_name": "Tesla Inc",
|
|
"current_price": "248.50",
|
|
"change_amount": "+12.30",
|
|
"change_percent": "+125.50%",
|
|
"volume": "2.1M",
|
|
"high_52w": "250.00",
|
|
"category": "52_week_gainer",
|
|
"rank_in_category": 1
|
|
}
|
|
],
|
|
"summary": {
|
|
"total_stocks": 500,
|
|
"most_active_count": 170,
|
|
"gainers_count": 330,
|
|
"unique_symbols": 485,
|
|
"overlap_count": 15
|
|
},
|
|
"performance": {
|
|
"elapsed_time_seconds": 18.5,
|
|
"most_active_time": 3.1,
|
|
"gainers_time": 15.4,
|
|
"parallel_execution": true
|
|
}
|
|
},
|
|
"metadata": {
|
|
"sources": ["finance.yahoo.com/most-active", "finance.yahoo.com/52-week-gainers"],
|
|
"method": "parallel_scraping_with_intelligent_rate_limiting",
|
|
"categories": ["most_active", "52_week_gainer"]
|
|
}
|
|
}
|
|
```
|
|
|
|
**Parameters**:
|
|
- `n`: Total number of trending stocks to return (default: 500). Final result is limited to this number.
|
|
- `most_active_limit`: Number of most active stocks to include (default: all available ~170 stocks)
|
|
- `gainers_limit`: Number of 52-week gainers to fetch (default: calculated to reach target `n`)
|
|
|
|
**Parameter Coordination**:
|
|
- **Default Behavior**: `n=500`, fetches all most active (~170) + calculates gainers needed (~330)
|
|
- **Custom Total**: Set `n` to control final result size, other parameters auto-adjust
|
|
- **Custom Mix**: Specify `most_active_limit` and/or `gainers_limit` for precise control
|
|
- **Priority**: most_active stocks prioritized, then gainers by rank when limiting to `n`
|
|
|
|
**Performance Notes**:
|
|
- **Default Mode** (n=500): ~15-30 seconds for 500 trending stocks
|
|
- **Fast Mode** (n=200): ~5-10 seconds for 200 trending stocks
|
|
- **Comprehensive Mode** (n=1000+): ~30-60 seconds for large datasets
|
|
- Automatic pagination based on calculated gainers_limit (approximately 200 stocks per page)
|
|
- Parallel execution for optimal performance
|
|
- Smart deduplication to handle overlapping stocks
|
|
|
|
**Categories**:
|
|
- `most_active`: High trading volume, immediate market attention
|
|
- `52_week_gainer`: Strong long-term price performance
|
|
- Stocks may appear in both categories (indicated by overlap_count)
|
|
"""
|
|
try:
|
|
start_time = datetime.now()
|
|
|
|
# Parameter coordination logic
|
|
# 1. If most_active_limit is None, we'll get all available (~170)
|
|
expected_most_active = most_active_limit if most_active_limit is not None else 170
|
|
|
|
# 2. Calculate gainers_limit if not specified to reach target n
|
|
if gainers_limit is None:
|
|
# Calculate how many gainers we need to reach target n
|
|
target_gainers = max(50, n - expected_most_active) # At least 50 gainers
|
|
else:
|
|
target_gainers = gainers_limit
|
|
|
|
# 3. Calculate pages needed for gainers (approximately 200 stocks per page)
|
|
gainers_pages = min(max(1, (target_gainers + 199) // 200), 7) # Ceiling division, max 7 pages
|
|
|
|
logger.info(f"🔥 Getting trending stocks (n={n}, most_active={most_active_limit or 'all'}, target_gainers={target_gainers}, auto_pages={gainers_pages})")
|
|
|
|
# Parallel execution for better performance
|
|
most_active_task = yahoo_most_active_service.get_most_active_stocks(limit=most_active_limit)
|
|
gainers_task = yahoo_52week_gainers_service.get_52week_gainers(limit=target_gainers, max_pages=gainers_pages)
|
|
|
|
# Wait for both tasks to complete
|
|
most_active_result, gainers_result = await asyncio.gather(most_active_task, gainers_task)
|
|
|
|
# Check for errors
|
|
if not most_active_result['success']:
|
|
logger.error(f"❌ Most active stocks error: {most_active_result.get('error')}")
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail=f"Failed to fetch most active stocks: {most_active_result.get('error', 'Unknown error')}"
|
|
)
|
|
|
|
if not gainers_result['success']:
|
|
logger.error(f"❌ 52-week gainers error: {gainers_result.get('error')}")
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail=f"Failed to fetch 52-week gainers: {gainers_result.get('error', 'Unknown error')}"
|
|
)
|
|
|
|
# Extract data
|
|
most_active_stocks = most_active_result['data']['stocks']
|
|
gainers_stocks = gainers_result['data']['stocks']
|
|
|
|
# Track timing
|
|
most_active_time = most_active_result['data'].get('elapsed_time_seconds', 0)
|
|
gainers_time = gainers_result['data'].get('elapsed_time_seconds', 0)
|
|
|
|
# Normalize and categorize stocks
|
|
trending_stocks = []
|
|
seen_symbols = set()
|
|
overlap_count = 0
|
|
|
|
# Add most active stocks
|
|
for i, stock in enumerate(most_active_stocks[:most_active_limit]):
|
|
symbol = stock.get('symbol', '').upper()
|
|
if symbol:
|
|
trending_stock = {
|
|
'symbol': symbol,
|
|
'company_name': stock.get('company_name', 'N/A'),
|
|
'current_price': stock.get('current_price', stock.get('price_raw', 'N/A')),
|
|
'change_amount': stock.get('change_amount', stock.get('change_raw', 'N/A')),
|
|
'change_percent': stock.get('change_percent', stock.get('change_percent_raw', 'N/A')),
|
|
'volume': stock.get('volume', stock.get('volume_raw', 'N/A')),
|
|
'category': 'most_active',
|
|
'rank_in_category': i + 1,
|
|
'scraped_at': stock.get('scraped_at')
|
|
}
|
|
|
|
# Add average volume if available
|
|
if 'avg_volume' in stock or 'avg_volume_raw' in stock:
|
|
trending_stock['avg_volume'] = stock.get('avg_volume', stock.get('avg_volume_raw'))
|
|
|
|
trending_stocks.append(trending_stock)
|
|
seen_symbols.add(symbol)
|
|
|
|
# Add 52-week gainers
|
|
for i, stock in enumerate(gainers_stocks[:gainers_limit]):
|
|
symbol = stock.get('symbol', '').upper()
|
|
if symbol:
|
|
# Check for overlap
|
|
is_overlap = symbol in seen_symbols
|
|
if is_overlap:
|
|
overlap_count += 1
|
|
# Find and update existing stock to indicate it's in both categories
|
|
for existing_stock in trending_stocks:
|
|
if existing_stock['symbol'] == symbol:
|
|
existing_stock['category'] = 'both'
|
|
existing_stock['gainer_rank'] = i + 1
|
|
# Add 52w high if available
|
|
if 'high_52w' in stock:
|
|
existing_stock['high_52w'] = stock['high_52w']
|
|
break
|
|
else:
|
|
trending_stock = {
|
|
'symbol': symbol,
|
|
'company_name': stock.get('company_name', 'N/A'),
|
|
'current_price': stock.get('current_price', 'N/A'),
|
|
'change_amount': stock.get('change_amount', 'N/A'),
|
|
'change_percent': stock.get('change_percent', 'N/A'),
|
|
'volume': stock.get('volume', 'N/A'),
|
|
'category': '52_week_gainer',
|
|
'rank_in_category': i + 1,
|
|
'scraped_at': stock.get('scraped_at')
|
|
}
|
|
|
|
# Add 52w high if available
|
|
if 'high_52w' in stock:
|
|
trending_stock['high_52w'] = stock['high_52w']
|
|
|
|
trending_stocks.append(trending_stock)
|
|
seen_symbols.add(symbol)
|
|
|
|
# Limit final result to n stocks (prioritize most_active, then gainers by rank)
|
|
if len(trending_stocks) > n:
|
|
# Sort to prioritize most_active and low ranks
|
|
trending_stocks.sort(key=lambda x: (
|
|
x['category'] != 'most_active', # most_active first
|
|
x['category'] == '52_week_gainer', # then gainers
|
|
x['rank_in_category'] # then by rank within category
|
|
))
|
|
trending_stocks = trending_stocks[:n]
|
|
|
|
# Calculate final metrics
|
|
total_time = (datetime.now() - start_time).total_seconds()
|
|
unique_symbols = len(set(stock['symbol'] for stock in trending_stocks))
|
|
most_active_count = len([s for s in trending_stocks if s['category'] in ['most_active', 'both']])
|
|
gainers_count = len([s for s in trending_stocks if s['category'] in ['52_week_gainer', 'both']])
|
|
|
|
logger.info(f"✅ Successfully returned {len(trending_stocks)} trending stocks (target: {n}, unique: {unique_symbols}) in {total_time:.1f}s")
|
|
|
|
return {
|
|
"success": True,
|
|
"message": f"Retrieved {len(trending_stocks)} trending stocks ({most_active_count} most active + {gainers_count} gainers) in {total_time:.1f}s",
|
|
"data": {
|
|
"trending_stocks": trending_stocks,
|
|
"summary": {
|
|
"total_stocks": len(trending_stocks),
|
|
"most_active_count": most_active_count,
|
|
"gainers_count": gainers_count,
|
|
"unique_symbols": unique_symbols,
|
|
"overlap_count": overlap_count
|
|
},
|
|
"performance": {
|
|
"elapsed_time_seconds": round(total_time, 1),
|
|
"most_active_time": round(most_active_time, 1),
|
|
"gainers_time": round(gainers_time, 1),
|
|
"parallel_execution": True
|
|
},
|
|
"scraped_at": datetime.now().isoformat()
|
|
},
|
|
"metadata": {
|
|
"sources": [
|
|
"finance.yahoo.com/markets/stocks/most-active/",
|
|
"finance.yahoo.com/markets/stocks/52-week-gainers/"
|
|
],
|
|
"method": "parallel_scraping_with_intelligent_rate_limiting",
|
|
"categories": ["most_active", "52_week_gainer", "both"],
|
|
"rate_limit_bypass": "curl_cffi_chrome_impersonation",
|
|
"deduplication": "symbol_based_with_category_merge"
|
|
}
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"❌ Unexpected error in get_trending_stocks: {e}")
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Internal server error while fetching trending stocks: {str(e)}"
|
|
) |