""" 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)}" )