diff --git a/backend/app/services/stock_universe.py b/backend/app/services/stock_universe.py index 0867d5c..339e782 100644 --- a/backend/app/services/stock_universe.py +++ b/backend/app/services/stock_universe.py @@ -14,7 +14,7 @@ from sqlalchemy import select, and_, func from sqlalchemy.orm import selectinload from app.core.database import AsyncSessionLocal -from app.models.models import Stock, StockDailyPrice +from app.models.models import StockDailyPrice logger = logging.getLogger(__name__) @@ -40,36 +40,16 @@ class StockUniverse: market: Optional[str] = None, active_only: bool = True ) -> List[Dict[str, Any]]: - """DB에서 전체 종목 리스트 조회 + """FinanceDataReader로 전체 종목 리스트 조회 (DB 대신) Args: market: 'KOSPI' | 'KOSDAQ' | None - active_only: 활성 종목만 조회 + active_only: 활성 종목만 조회 (FDR은 자동으로 활성종목만) Returns: [{'code': '005930', 'name': '삼성전자', 'market': 'KOSPI'}, ...] """ - async with AsyncSessionLocal() as session: - stmt = select(Stock) - - if active_only: - stmt = stmt.where(Stock.is_active == True) - - if market: - stmt = stmt.where(Stock.market == market) - - result = await session.execute(stmt) - stocks = result.scalars().all() - - return [ - { - 'code': s.stock_code, - 'name': s.stock_name, - 'market': s.market, - 'sector': getattr(s, 'sector', None), - } - for s in stocks - ] + return await get_all_krx_stocks_from_fdr(market=market) @staticmethod async def get_filtered_stocks( @@ -88,26 +68,25 @@ class StockUniverse: if reference_date is None: reference_date = datetime.now().date() - timedelta(days=1) - async with AsyncSessionLocal() as session: - # 1. 기본 종목 필터 - stmt = select(Stock).where(Stock.is_active == True) - - if filter_config.market: - stmt = stmt.where(Stock.market == filter_config.market) - - if filter_config.exclude_admin: - # 종목명에 '관리' 포함 제외 - stmt = stmt.where(~Stock.stock_name.like('%관리%')) - - result = await session.execute(stmt) - stocks = result.scalars().all() - stock_codes = [s.stock_code for s in stocks] - - if not stock_codes: - return [] - - # 2. 가격/거래량 필터 (최근 20일 평균) - if any([filter_config.min_volume, filter_config.min_price, filter_config.max_price]): + # 1. FDR로 전체 종목 조회 + all_stocks = await get_all_krx_stocks_from_fdr(market=filter_config.market) + + if not all_stocks: + logger.warning("FDR 조회 실패, 빈 리스트 반환") + return [] + + # 관리종목 제외 + if filter_config.exclude_admin: + all_stocks = [s for s in all_stocks if '관리' not in s['name']] + + stock_codes = [s['code'] for s in all_stocks] + + if not stock_codes: + return [] + + # 2. DB에서 가격/거래량 필터 (최근 20일 평균) + if any([filter_config.min_volume, filter_config.min_price, filter_config.max_price]): + async with AsyncSessionLocal() as session: start_date = reference_date - timedelta(days=30) # 종목별 평균 계산 @@ -144,13 +123,13 @@ class StockUniverse: filtered_codes.append(row.stock_code) stock_codes = filtered_codes - - # 3. Top N 제한 - if filter_config.top_n and len(stock_codes) > filter_config.top_n: - stock_codes = stock_codes[:filter_config.top_n] - - logger.info(f"필터링 완료: {len(stock_codes)}개 종목") - return stock_codes + + # 3. Top N 제한 + if filter_config.top_n and len(stock_codes) > filter_config.top_n: + stock_codes = stock_codes[:filter_config.top_n] + + logger.info(f"필터링 완료: {len(stock_codes)}개 종목") + return stock_codes @staticmethod async def get_liquid_stocks( @@ -193,63 +172,81 @@ class StockUniverse: return await StockUniverse.get_filtered_stocks(filter_config) @staticmethod - def get_preset_universes() -> Dict[str, StockFilter]: + def get_preset_universes() -> Dict[str, Dict[str, Any]]: """사전 정의된 유니버스 프리셋 Returns: - {'large_cap': StockFilter(...), ...} + {'large_cap': {'description': '...', 'filter': StockFilter(...)}, ...} """ return { - 'all': StockFilter( - exclude_admin=True - ), - 'kospi_all': StockFilter( - market='KOSPI', - exclude_admin=True - ), - 'kosdaq_all': StockFilter( - market='KOSDAQ', - exclude_admin=True - ), - 'large_cap': StockFilter( - market='KOSPI', - min_market_cap=10000, # 1조 이상 - min_volume=100000, - exclude_admin=True, - top_n=100 - ), - 'mid_cap': StockFilter( - market='KOSPI', - min_market_cap=3000, # 3천억 이상 - max_price=100000, # 10만원 이하 - min_volume=50000, - exclude_admin=True, - top_n=200 - ), - 'liquid_kosdaq': StockFilter( - market='KOSDAQ', - min_volume=200000, # 20만주 이상 - min_price=3000, # 3천원 이상 - exclude_admin=True, - top_n=100 - ), - 'penny_stocks': StockFilter( - min_price=1000, - max_price=5000, # 1천~5천원 - min_volume=500000, # 거래량 많은 것만 - exclude_admin=True, - top_n=50 - ), + 'all': { + 'description': 'KOSPI+KOSDAQ 전체 (관리종목 제외)', + 'filter': StockFilter(exclude_admin=True) + }, + 'kospi_all': { + 'description': 'KOSPI 전체', + 'filter': StockFilter(market='KOSPI', exclude_admin=True) + }, + 'kosdaq_all': { + 'description': 'KOSDAQ 전체', + 'filter': StockFilter(market='KOSDAQ', exclude_admin=True) + }, + 'large_cap': { + 'description': 'KOSPI 대형주 (거래량 상위 100개)', + 'filter': StockFilter( + market='KOSPI', + min_volume=100000, + min_price=10000, + exclude_admin=True, + top_n=100 + ) + }, + 'mid_cap': { + 'description': 'KOSPI 중형주 (상위 200개)', + 'filter': StockFilter( + market='KOSPI', + min_price=5000, + max_price=100000, + min_volume=50000, + exclude_admin=True, + top_n=200 + ) + }, + 'liquid_kosdaq': { + 'description': 'KOSDAQ 유동성 높은 종목 (상위 100개)', + 'filter': StockFilter( + market='KOSDAQ', + min_volume=200000, + min_price=3000, + exclude_admin=True, + top_n=100 + ) + }, + 'penny_stocks': { + 'description': '저가주 (1천~5천원, 거래량 많은 50개)', + 'filter': StockFilter( + min_price=1000, + max_price=5000, + min_volume=500000, + exclude_admin=True, + top_n=50 + ) + }, } # ── FinanceDataReader 연동 (API 없이 전체 종목 조회) ───────────────────────────────── -async def get_all_krx_stocks_from_fdr() -> List[Dict[str, Any]]: +async def get_all_krx_stocks_from_fdr( + market: Optional[str] = None +) -> List[Dict[str, Any]]: """FinanceDataReader로 KRX 전체 종목 조회 DB에 없어도 사용 가능! + Args: + market: 'KOSPI' | 'KOSDAQ' | None (전체) + Returns: [{'code': '005930', 'name': '삼성전자', 'market': 'KOSPI'}, ...] """ @@ -259,6 +256,10 @@ async def get_all_krx_stocks_from_fdr() -> List[Dict[str, Any]]: # KRX 전체 종목 df_krx = fdr.StockListing('KRX') + # 마켓 필터 + if market: + df_krx = df_krx[df_krx['Market'] == market] + stocks = [] for _, row in df_krx.iterrows(): stocks.append({ @@ -269,7 +270,7 @@ async def get_all_krx_stocks_from_fdr() -> List[Dict[str, Any]]: 'listing_date': row.get('ListingDate', None), }) - logger.info(f"FDR에서 {len(stocks)}개 종목 조회") + logger.info(f"FDR에서 {len(stocks)}개 종목 조회 (market={market})") return stocks except ImportError: