feat(stocks): Wikipedia 인덱스 구성 종목 조회 API 추가

GET /stocks/index/{index_name} 엔드포인트 추가.
sp500/nasdaq100 구성 종목을 Wikipedia에서 실시간 파싱하여 반환.
24시간 Redis 캐시 및 30초 타임아웃 적용.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
I Luk Kim 5 months ago
parent 5c9d1f0d93
commit cbc0f93123

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

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