""" 앙상블 모델 학습 스크립트 데이터 소스를 선택하여 LightGBM + PyTorch LSTM + Random Forest 앙상블 모델을 학습합니다. - DB 모드(기본): stock_daily_prices 테이블의 KRX 히스토리컬 데이터 사용 - API 모드: KIS API에서 실시간 차트 데이터 수집 사용법: python train_ensemble.py # DB 기반 전체 학습 python train_ensemble.py --source db --limit 20 # DB에서 20종목 python train_ensemble.py --source api --test # KIS API로 10종목 (기존 방식) python train_ensemble.py --min-days 500 # 최소 500일 데이터 있는 종목만 """ import asyncio import sys import argparse import logging from pathlib import Path from datetime import datetime import numpy as np import pandas as pd # 프로젝트 루트를 Python 경로에 추가 sys.path.insert(0, str(Path(__file__).parent)) from app.services.feature_engineering import ( chart_data_to_dataframe, db_data_to_dataframe, extract_features, prepare_training_data, ) from app.services.ml_ensemble import MLEnsemble logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", ) logger = logging.getLogger(__name__) # ─── DB 기반 데이터 수집 ─── async def collect_training_data_from_db(limit: int = 200, min_days: int = 200) -> tuple: """DB의 stock_daily_prices에서 학습 데이터 수집 Args: limit: 최대 종목 수 min_days: 최소 데이터 일수 (이보다 적으면 제외) Returns: (X_tabular, X_seq, y) """ from sqlalchemy import text from app.core.database import AsyncSessionLocal all_X_tabular = [] all_X_seq = [] all_y = [] async with AsyncSessionLocal() as session: # 충분한 데이터가 있는 종목 조회 result = await session.execute( text(""" SELECT stock_code, stock_name, COUNT(*) as cnt FROM stock_daily_prices GROUP BY stock_code, stock_name HAVING COUNT(*) >= :min_days ORDER BY cnt DESC LIMIT :limit """), {"min_days": min_days, "limit": limit}, ) stock_list = [(row[0], row[1], row[2]) for row in result.fetchall()] if not stock_list: logger.error(f"DB에 {min_days}일 이상 데이터가 있는 종목이 없습니다") logger.error("먼저 python collect_krx_data.py 를 실행하세요") return np.array([]), np.array([]), np.array([]) logger.info(f"DB에서 {len(stock_list)}개 종목 조회 (최소 {min_days}일)") for i, (stock_code, stock_name, day_count) in enumerate(stock_list): logger.info(f"[{i+1}/{len(stock_list)}] {stock_code} ({stock_name}): {day_count}일 데이터") try: # DB에서 OHLCV 조회 rows = await session.execute( text(""" SELECT trade_date, open_price, high_price, low_price, close_price, volume FROM stock_daily_prices WHERE stock_code = :code ORDER BY trade_date ASC """), {"code": stock_code}, ) data = [ { "trade_date": r[0], "open_price": r[1], "high_price": r[2], "low_price": r[3], "close_price": r[4], "volume": r[5], } for r in rows.fetchall() ] if len(data) < min_days: continue # DataFrame 변환 및 피처 추출 df = db_data_to_dataframe(data) features_df = extract_features(df) # 학습 데이터 준비 X_tab, X_seq, y, indices = prepare_training_data( df, features_df, forecast_days=3, up_threshold=0.015, down_threshold=-0.015, ) if len(X_tab) == 0: logger.warning(f" {stock_code}: 유효 샘플 없음") continue all_X_tabular.append(X_tab) all_X_seq.append(X_seq) all_y.append(y) logger.info( f" {stock_code}: {len(X_tab)}개 샘플 " f"(UP:{np.sum(y==2)} HOLD:{np.sum(y==1)} DOWN:{np.sum(y==0)})" ) except Exception as e: logger.error(f" {stock_code} 처리 실패: {e}") if not all_X_tabular: return np.array([]), np.array([]), np.array([]) X_tabular = np.vstack(all_X_tabular) X_seq = np.vstack(all_X_seq) y = np.concatenate(all_y) return X_tabular, X_seq, y # ─── KIS API 기반 데이터 수집 (기존 방식) ─── async def collect_training_data_from_api(stock_list: list, kis_api) -> tuple: """여러 종목의 차트 데이터를 KIS API에서 수집하여 학습 데이터로 변환""" all_X_tabular = [] all_X_seq = [] all_y = [] for i, (stock_code, stock_name) in enumerate(stock_list): logger.info(f"[{i+1}/{len(stock_list)}] 데이터 수집: {stock_code} ({stock_name})") try: chart_resp = await kis_api.get_daily_chart(stock_code, "D") chart_data = chart_resp.get("output", []) if not chart_data or len(chart_data) < 80: logger.warning(f" {stock_code}: 데이터 부족 ({len(chart_data) if chart_data else 0}일)") continue df = chart_data_to_dataframe(chart_data) features_df = extract_features(df) X_tab, X_seq, y, indices = prepare_training_data( df, features_df, forecast_days=3, up_threshold=0.015, down_threshold=-0.015, ) if len(X_tab) == 0: logger.warning(f" {stock_code}: 유효 샘플 없음") continue all_X_tabular.append(X_tab) all_X_seq.append(X_seq) all_y.append(y) logger.info(f" {stock_code}: {len(X_tab)}개 샘플 (UP:{np.sum(y==2)} HOLD:{np.sum(y==1)} DOWN:{np.sum(y==0)})") except Exception as e: logger.error(f" {stock_code} 수집 실패: {e}") await asyncio.sleep(3) if not all_X_tabular: return np.array([]), np.array([]), np.array([]) X_tabular = np.vstack(all_X_tabular) X_seq = np.vstack(all_X_seq) y = np.concatenate(all_y) return X_tabular, X_seq, y async def get_stock_list_from_api(kis_api, limit: int = 50) -> list: """거래량 상위 종목 조회 (KIS API)""" stocks = [] try: kospi = await kis_api.get_volume_rank("J") if kospi and kospi.get("output"): for item in kospi["output"]: name = item.get("hts_kor_isnm", "") skip_keywords = ["ETF", "레버리지", "인버스", "KODEX", "선물", "TIGER", "ARIRANG"] if any(k in name for k in skip_keywords): continue stocks.append((item["mksc_shrn_iscd"], name)) except Exception as e: logger.error(f"KOSPI 종목 조회 실패: {e}") await asyncio.sleep(3) try: kosdaq = await kis_api.get_volume_rank("Q") if kosdaq and kosdaq.get("output"): for item in kosdaq["output"]: name = item.get("hts_kor_isnm", "") skip_keywords = ["ETF", "레버리지", "인버스", "KODEX", "선물", "TIGER", "ARIRANG"] if any(k in name for k in skip_keywords): continue stocks.append((item["mksc_shrn_iscd"], name)) except Exception as e: logger.error(f"KOSDAQ 종목 조회 실패: {e}") logger.info(f"총 {len(stocks)}개 종목 조회 완료") return stocks[:limit] # ─── 메인 ─── async def main(): parser = argparse.ArgumentParser(description="앙상블 모델 학습") parser.add_argument("--source", choices=["db", "api"], default="db", help="데이터 소스 (기본: db)") parser.add_argument("--limit", type=int, default=200, help="학습 종목 수 (기본: 200)") parser.add_argument("--test", action="store_true", help="테스트 모드 (10종목)") parser.add_argument("--min-days", type=int, default=200, help="DB 모드: 최소 데이터 일수 (기본: 200)") args = parser.parse_args() limit = 10 if args.test else args.limit logger.info(f"데이터 소스: {args.source.upper()}") logger.info(f"{'테스트 모드' if args.test else '전체 학습'}: {limit}개 종목") start_time = datetime.now() # 데이터 수집 if args.source == "db": logger.info("=" * 60) logger.info("DB(stock_daily_prices)에서 학습 데이터 수집 중...") X_tabular, X_seq, y = await collect_training_data_from_db( limit=limit, min_days=args.min_days ) else: # KIS API 모드 from app.services.korea_investment_api import KoreaInvestmentAPI kis_api = KoreaInvestmentAPI() stock_list = await get_stock_list_from_api(kis_api, limit=limit) if not stock_list: logger.error("종목 리스트를 가져올 수 없습니다") return logger.info("=" * 60) logger.info("KIS API에서 학습 데이터 수집 중...") X_tabular, X_seq, y = await collect_training_data_from_api(stock_list, kis_api) if len(X_tabular) == 0: logger.error("학습 데이터가 없습니다") return logger.info(f"총 학습 데이터: {len(X_tabular):,}개 샘플") logger.info(f" 클래스 분포: UP={np.sum(y==2):,}, HOLD={np.sum(y==1):,}, DOWN={np.sum(y==0):,}") # 앙상블 학습 logger.info("=" * 60) logger.info("앙상블 모델 학습 중...") ensemble = MLEnsemble() metrics = ensemble.train(X_tabular, X_seq, y) # 결과 출력 duration = (datetime.now() - start_time).total_seconds() logger.info("=" * 60) logger.info("학습 완료!") logger.info(f" 소요 시간: {duration:.1f}초 ({duration/60:.1f}분)") logger.info(f" 학습 결과:") for key, value in metrics.items(): logger.info(f" {key}: {value}") logger.info("=" * 60) if __name__ == "__main__": asyncio.run(main())