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
parent
5c9d1f0d93
commit
cbc0f93123
@ -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…
Reference in New Issue