""" 매일 아침 장 시작 전(9시) 실행되는 배치 분석 스크립트 전체 거래 종목을 분석하여 DB에 저장 - 차트 데이터 조회 - 기술적 지표 계산 - 매매 신호 생성 - DB 저장 실행: python batch_daily_analysis.py Cron: 0 9 * * 1-5 cd /path/to/auto-stock/backend && python batch_daily_analysis.py """ import asyncio import sys from datetime import datetime, date from pathlib import Path # 프로젝트 루트를 Python 경로에 추가 sys.path.insert(0, str(Path(__file__).parent)) from sqlalchemy import select, delete from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession from sqlalchemy.orm import sessionmaker from app.core.config import settings from app.models.models import StockAnalysis from app.services.korea_investment_api import KoreaInvestmentAPI from app.api.endpoints.recommendations import analyze_stock_with_indicators import logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) # 데이터베이스 연결 engine = create_async_engine( settings.DATABASE_URL, echo=False, pool_pre_ping=True, pool_size=10, max_overflow=20 ) AsyncSessionLocal = sessionmaker( engine, class_=AsyncSession, expire_on_commit=False ) async def get_all_tradable_stocks() -> list[tuple[str, str]]: """거래 가능한 전체 종목 조회 (안전한 재시도 적용)""" kis_api = KoreaInvestmentAPI() logger.info("Fetching all tradable stocks from KIS API...") stocks = [] # KOSPI 상위 종목 조회 try: # 재시도 로직이 내장되어 있으므로 sleep 제거 (쓰로틀링은 자동 적용) kospi_result = await kis_api.get_volume_rank("J") if kospi_result and kospi_result.get("output"): for item in kospi_result["output"]: stocks.append(( item["mksc_shrn_iscd"], item["hts_kor_isnm"] )) logger.info(f"✅ Found {len(stocks)} KOSPI stocks") except Exception as e: logger.error(f"❌ Failed to fetch KOSPI stocks: {e}") # KOSDAQ 상위 종목 조회 try: kosdaq_result = await kis_api.get_volume_rank("Q") if kosdaq_result and kosdaq_result.get("output"): kospi_count = len(stocks) for item in kosdaq_result["output"]: stocks.append(( item["mksc_shrn_iscd"], item["hts_kor_isnm"] )) logger.info(f"✅ Found {len(stocks) - kospi_count} KOSDAQ stocks") except Exception as e: logger.error(f"❌ Failed to fetch KOSDAQ stocks: {e}") logger.info(f"📊 Total tradable stocks: {len(stocks)}") return stocks async def analyze_and_save_stock( session: AsyncSession, stock_code: str, stock_name: str, analysis_date: datetime ) -> bool: """종목 분석 후 DB 저장""" try: # 종목 분석 (기존 함수 재사용) result = await analyze_stock_with_indicators(stock_code, stock_name) if not result: logger.warning(f"No analysis result for {stock_code}") return False # DB에 저장 analysis = StockAnalysis( stock_code=stock_code, stock_name=stock_name, analysis_date=analysis_date, current_price=result.current_price, change_rate=result.change_rate, volume=result.volume, rsi=result.rsi, ma5=result.ma5, ma20=result.ma20, ma60=result.ma60, stochastic_k=result.stochastic_k, stochastic_d=result.stochastic_d, bollinger_upper=result.bollinger_upper, bollinger_lower=result.bollinger_lower, bollinger_middle=result.bollinger_middle, signals=result.signals, score=result.score ) session.add(analysis) await session.commit() logger.info(f"✅ Saved analysis for {stock_code} ({stock_name}): score={result.score}") return True except Exception as e: logger.error(f"❌ Failed to analyze {stock_code}: {e}") await session.rollback() return False async def batch_analyze_all_stocks(limit: int = None): """전체 종목 배치 분석 (안전한 재시도 및 쓰로틀링 적용)""" start_time = datetime.now() analysis_date = date.today() logger.info(f"🚀 Starting daily batch analysis for {analysis_date}") logger.info(f"⚙️ Rate Limiting: {settings.API_MIN_REQUEST_INTERVAL}초 간격, 최대 재시도 {settings.API_MAX_RETRIES}회") # KIS API 인스턴스 kis_api = KoreaInvestmentAPI() async with AsyncSessionLocal() as session: # 1. 오늘 날짜 기존 데이터 삭제 logger.info("🗑️ Deleting old analysis data...") await session.execute( delete(StockAnalysis).where( StockAnalysis.analysis_date >= analysis_date ) ) await session.commit() logger.info("✅ Old data deleted") # 2. 전체 종목 조회 all_stocks = await get_all_tradable_stocks() if limit: all_stocks = all_stocks[:limit] logger.info(f"🧪 Limited to {limit} stocks for testing") # 3. 배치 처리 (쓰로틀링은 KIS API 클래스에서 자동 처리) success_count = 0 fail_count = 0 for i, (stock_code, stock_name) in enumerate(all_stocks, 1): logger.info(f"[{i}/{len(all_stocks)}] Analyzing {stock_code} ({stock_name})...") success = await analyze_and_save_stock( session, stock_code, stock_name, analysis_date ) if success: success_count += 1 else: fail_count += 1 # 30개마다 통계 출력 if i % 30 == 0: kis_api.log_monitor_stats() # 4. 결과 요약 및 최종 통계 end_time = datetime.now() duration = (end_time - start_time).total_seconds() logger.info("=" * 60) logger.info(f"✅ Batch analysis completed!") logger.info(f"📊 Success: {success_count} / Total: {len(all_stocks)}") logger.info(f"❌ Failed: {fail_count}") logger.info(f"⏱️ Duration: {duration:.1f} seconds ({duration/60:.1f} minutes)") logger.info(f"📅 Analysis Date: {analysis_date}") # API 통계 출력 stats = kis_api.get_monitor_stats() logger.info(f"📡 API 통계:") logger.info(f" - 총 호출: {stats['total_calls']}건") logger.info(f" - 오류: {stats['total_errors']}건 ({stats['error_rate']:.1%})") logger.info(f" - 재시도: {stats['total_retries']}건") logger.info(f" - 평균 응답 시간: {stats['avg_duration']:.2f}초") logger.info("=" * 60) async def main(): """메인 실행 함수""" import argparse parser = argparse.ArgumentParser(description='Daily Stock Analysis Batch') parser.add_argument('--limit', type=int, help='Limit number of stocks (for testing)') parser.add_argument('--test', action='store_true', help='Test mode (analyze 10 stocks)') args = parser.parse_args() if args.test: logger.info("🧪 TEST MODE: Analyzing 10 stocks only") await batch_analyze_all_stocks(limit=10) elif args.limit: logger.info(f"📊 Custom limit: {args.limit} stocks") await batch_analyze_all_stocks(limit=args.limit) else: logger.info("📊 Full analysis: All stocks") await batch_analyze_all_stocks() if __name__ == "__main__": asyncio.run(main())