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