diff --git a/app/api/v1/endpoints/stocks.py b/app/api/v1/endpoints/stocks.py index 3393b2c..5b6ef81 100644 --- a/app/api/v1/endpoints/stocks.py +++ b/app/api/v1/endpoints/stocks.py @@ -11,12 +11,73 @@ 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}") +@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 index constituents from Wikipedia + + Returns current constituents of the specified stock index: + - Stock symbol + - Company name + - GICS Sector + - GICS Sub-Industry + + **Supported indexes**: `sp500`, `nasdaq100` + + **Data Source**: Wikipedia (List of S&P 500 companies / Nasdaq-100) + **Cache TTL**: 24 hours + """ + 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") @with_cache(namespace="stocks:most-active", ttl=3600, key_params=["limit"]) async def get_most_active_stocks( diff --git a/app/services/index_constituents_service.py b/app/services/index_constituents_service.py new file mode 100644 index 0000000..9cd43de --- /dev/null +++ b/app/services/index_constituents_service.py @@ -0,0 +1,94 @@ +""" +Index Constituents Service +S&P 500 / Nasdaq 100 구성 종목을 Wikipedia에서 조회 +""" + +import asyncio +import logging +from typing import Optional + +import pandas as pd + +logger = logging.getLogger("app.services.index_constituents") + + +class IndexConstituentsService: + INDEXES = { + "sp500": { + "url": "https://en.wikipedia.org/wiki/List_of_S%26P_500_companies", + "table_index": 0, + "ticker_col": "Symbol", + "name_col": "Security", + "sector_col": "GICS Sector", + "industry_col": "GICS Sub-Industry", + }, + "nasdaq100": { + "url": "https://en.wikipedia.org/wiki/Nasdaq-100", + "table_index": 4, + "ticker_col": "Ticker", + "name_col": "Company", + "sector_col": "GICS Sector", + "industry_col": "GICS Sub-Industry", + }, + } + + def _fetch_constituents_sync(self, index_name: str) -> dict: + config = self.INDEXES[index_name] + url = config["url"] + ticker_col = config["ticker_col"] + name_col = config["name_col"] + sector_col = config["sector_col"] + industry_col = config["industry_col"] + + logger.info(f"Fetching Wikipedia tables from {url}") + tables = pd.read_html(url, flavor="lxml") + + # Try configured table index first + df = None + table_index = config["table_index"] + if table_index < len(tables) and ticker_col in tables[table_index].columns: + df = tables[table_index] + logger.info(f"Using configured table index {table_index}") + else: + # Fallback: scan all tables for ticker column + for i, tbl in enumerate(tables): + if ticker_col in tbl.columns: + df = tbl + logger.info(f"Fallback: found ticker column '{ticker_col}' in table {i}") + break + + if df is None: + raise ValueError( + f"Could not find table with column '{ticker_col}' in any of the {len(tables)} tables" + ) + + constituents = [] + for _, row in df.iterrows(): + symbol = str(row.get(ticker_col, "")).strip() + if not symbol or symbol == "nan": + continue + constituents.append({ + "symbol": symbol, + "name": str(row.get(name_col, "")).strip(), + "sector": str(row.get(sector_col, "")).strip() if sector_col in df.columns else None, + "industry": str(row.get(industry_col, "")).strip() if industry_col in df.columns else None, + }) + + return { + "success": True, + "index": index_name, + "count": len(constituents), + "constituents": constituents, + } + + async def get_constituents(self, index_name: str) -> dict: + if index_name not in self.INDEXES: + raise ValueError(f"Unknown index '{index_name}'. Supported: {list(self.INDEXES.keys())}") + + loop = asyncio.get_event_loop() + result = await loop.run_in_executor(None, self._fetch_constituents_sync, index_name) + logger.info(f"Retrieved {result['count']} constituents for {index_name}") + return result + + +index_constituents_service = IndexConstituentsService()