""" 종목 유니버스 관리 KOSPI/KOSDAQ 전체 종목 리스트 조회 및 필터링 """ import logging from typing import List, Dict, Any, Optional from datetime import date, datetime, timedelta from dataclasses import dataclass import pandas as pd from sqlalchemy import select, and_, func from sqlalchemy.orm import selectinload from app.core.database import AsyncSessionLocal from app.models.models import StockDailyPrice logger = logging.getLogger(__name__) @dataclass class StockFilter: """종목 필터 조건""" market: Optional[str] = None # 'KOSPI' | 'KOSDAQ' | None (전체) min_market_cap: Optional[float] = None # 최소 시가총액 (억원) min_volume: Optional[float] = None # 최소 일평균 거래량 (주) min_price: Optional[float] = None # 최소 주가 (원) max_price: Optional[float] = None # 최대 주가 (원) exclude_admin: bool = True # 관리종목 제외 sectors: Optional[List[str]] = None # 업종 필터 top_n: Optional[int] = None # 상위 N개 종목만 class StockUniverse: """종목 유니버스 관리자""" @staticmethod async def get_all_stocks( market: Optional[str] = None, active_only: bool = True ) -> List[Dict[str, Any]]: """FinanceDataReader로 전체 종목 리스트 조회 (DB 대신) Args: market: 'KOSPI' | 'KOSDAQ' | None active_only: 활성 종목만 조회 (FDR은 자동으로 활성종목만) Returns: [{'code': '005930', 'name': '삼성전자', 'market': 'KOSPI'}, ...] """ return await get_all_krx_stocks_from_fdr(market=market) @staticmethod async def get_filtered_stocks( filter_config: StockFilter, reference_date: Optional[date] = None ) -> List[str]: """필터 조건에 맞는 종목 코드 리스트 Args: filter_config: 필터 설정 reference_date: 기준일 (None이면 최근 영업일) Returns: ['005930', '000660', ...] """ if reference_date is None: reference_date = datetime.now().date() - timedelta(days=1) # 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) # 종목별 평균 계산 stmt = select( StockDailyPrice.stock_code, func.avg(StockDailyPrice.close_price).label('avg_price'), func.avg(StockDailyPrice.volume).label('avg_volume') ).where( and_( StockDailyPrice.stock_code.in_(stock_codes), StockDailyPrice.trade_date >= start_date, StockDailyPrice.trade_date <= reference_date ) ).group_by(StockDailyPrice.stock_code) result = await session.execute(stmt) rows = result.all() filtered_codes = [] for row in rows: avg_price = float(row.avg_price) if row.avg_price else 0 avg_volume = float(row.avg_volume) if row.avg_volume else 0 # 가격 필터 if filter_config.min_price and avg_price < filter_config.min_price: continue if filter_config.max_price and avg_price > filter_config.max_price: continue # 거래량 필터 if filter_config.min_volume and avg_volume < filter_config.min_volume: continue 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 @staticmethod async def get_liquid_stocks( market: str = 'KOSPI', top_n: int = 100 ) -> List[str]: """거래대금 상위 N개 종목 (유동성 높은 종목) Args: market: 'KOSPI' | 'KOSDAQ' top_n: 상위 N개 Returns: 종목 코드 리스트 """ filter_config = StockFilter( market=market, min_volume=100000, # 일 10만주 이상 min_price=5000, # 5천원 이상 exclude_admin=True, top_n=top_n ) return await StockUniverse.get_filtered_stocks(filter_config) @staticmethod async def get_kospi200_like( top_n: int = 200 ) -> List[str]: """KOSPI200 유사 종목 (시총 상위 200) 실제 KOSPI200 구성종목 데이터가 없으므로 시총 상위로 근사 """ filter_config = StockFilter( market='KOSPI', min_market_cap=5000, # 5천억 이상 min_volume=50000, exclude_admin=True, top_n=top_n ) return await StockUniverse.get_filtered_stocks(filter_config) @staticmethod def get_preset_universes() -> Dict[str, Dict[str, Any]]: """사전 정의된 유니버스 프리셋 Returns: {'large_cap': {'description': '...', 'filter': StockFilter(...)}, ...} """ return { '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( market: Optional[str] = None ) -> List[Dict[str, Any]]: """FinanceDataReader로 KRX 전체 종목 조회 DB에 없어도 사용 가능! Args: market: 'KOSPI' | 'KOSDAQ' | None (전체) Returns: [{'code': '005930', 'name': '삼성전자', 'market': 'KOSPI'}, ...] """ try: import FinanceDataReader as fdr # 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({ 'code': row['Code'], 'name': row['Name'], 'market': row['Market'], 'sector': row.get('Sector', None), 'listing_date': row.get('ListingDate', None), }) logger.info(f"FDR에서 {len(stocks)}개 종목 조회 (market={market})") return stocks except ImportError: logger.warning("FinanceDataReader 미설치. DB 폴백 사용") return await _get_stocks_from_db(market) except Exception as e: logger.warning(f"FDR 조회 실패: {e}. DB 폴백 사용") return await _get_stocks_from_db(market) async def _get_stocks_from_db(market: Optional[str] = None) -> List[Dict[str, Any]]: """FDR 실패 시 DB의 StockDailyPrice에서 고유 종목 코드 조회""" try: from sqlalchemy import distinct async with AsyncSessionLocal() as session: stmt = select(distinct(StockDailyPrice.stock_code)) result = await session.execute(stmt) codes = [row[0] for row in result.all()] logger.info(f"DB 폴백: {len(codes)}개 종목 조회 (market 필터 미적용)") return [{'code': c, 'name': c, 'market': market or 'KOSPI'} for c in codes] except Exception as e: logger.error(f"DB 폴백 조회 실패: {e}") return []