Files
auto-stock/backend/app/services/stock_universe.py
2026-03-07 13:16:01 +09:00

298 lines
10 KiB
Python

"""
종목 유니버스 관리
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 []