""" 매일 아침 장 시작 전(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 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, korea_investment_api from app.api.endpoints.recommendations import analyze_stock_for_batch import logging logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) logger = logging.getLogger(__name__) # 데이터베이스 연결 # SQLAlchemy asyncpg 드라이버 사용 (postgresql+asyncpg://) database_url = settings.DATABASE_URL if database_url.startswith("postgresql://"): database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1) engine = create_async_engine( 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(kis_api: KoreaInvestmentAPI, limit: int = 500) -> list[tuple[str, str]]: """시가총액 상위 종목 조회 (pykrx 사용) Args: kis_api: KIS API 인스턴스 (호환성 유지용, 미사용) limit: 조회할 종목 수 (기본 500개) Returns: [(stock_code, stock_name), ...] 시가총액 상위 종목 리스트 """ logger.info(f"Fetching top {limit} stocks by market cap from pykrx...") try: from pykrx import stock as pykrx_stock import pandas as pd today = datetime.now().strftime("%Y%m%d") stocks_with_cap = [] # ETF/특수 종목 제외 키워드 SKIP_KEYWORDS = [ "ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG", "KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX", "선물", "스팩", "리츠", "SPAC", ] # KOSPI + KOSDAQ 전체 종목 조회 for market in ["KOSPI", "KOSDAQ"]: try: # 시장 전체의 시가총액 데이터를 한 번에 조회 (최적화) cap_df = pykrx_stock.get_market_cap_by_ticker(today, market=market) logger.info(f" {market}: {len(cap_df)}개 종목 시가총액 조회") for ticker in cap_df.index: try: name = pykrx_stock.get_market_ticker_name(ticker) # ETF/특수 종목 필터링 if any(k in name for k in SKIP_KEYWORDS): continue market_cap = cap_df.loc[ticker, "시가총액"] stocks_with_cap.append((ticker, name, market_cap)) except Exception as e: logger.debug(f" Skip {ticker}: {e}") continue except Exception as e: logger.error(f"❌ Failed to fetch {market} stocks: {e}") # 시가총액 기준 내림차순 정렬 stocks_with_cap.sort(key=lambda x: x[2], reverse=True) # 상위 N개 선택 (시가총액 제외) top_stocks = [(code, name) for code, name, _ in stocks_with_cap[:limit]] logger.info(f"✅ Selected top {len(top_stocks)} stocks by market cap") if top_stocks: kospi_count = sum(1 for c, _ in top_stocks if c.startswith(('0', '1', '2', '3', '4', '5'))) kosdaq_count = len(top_stocks) - kospi_count logger.info(f" KOSPI: {kospi_count} stocks") logger.info(f" KOSDAQ: {kosdaq_count} stocks") return top_stocks except Exception as e: logger.error(f"❌ Failed to fetch stocks by market cap: {e}", exc_info=True) logger.warning("⚠️ Falling back to empty list") return [] async def analyze_and_save_stock( stock_code: str, stock_name: str, analysis_date: datetime ) -> str: """종목 분석 후 DB 저장 (독립 세션 사용 — 동시 처리 안전) ATR은 analyze_stock() 내부에서 이미 계산되어 result.atr_14에 포함됨. 이중 API 호출 없음. Returns: "ok" | "no_chart" | "price_filter" | "error" """ try: # 배치 전용 함수 사용 (거래량/점수 필터 없이 전 종목 지표 저장) result = await analyze_stock_for_batch(stock_code, stock_name) if not result: # for_batch=True 에서 None = 차트 데이터 없음 or 가격 0 등 실제 오류 logger.info(f"차트없음 {stock_code} ({stock_name}): KIS API 차트 데이터 없음") return "no_chart" # 5,000원 이하 저가주 제외 (CLAUDE.md 6.4) if result.current_price and result.current_price < 5000: logger.info(f"가격필터 {stock_code} ({stock_name}): {result.current_price:.0f}원 < 5,000원") return "price_filter" async with AsyncSessionLocal() as session: # 이미 오늘 분석된 종목이면 삭제 후 재삽입 (중복 방지) await session.execute( delete(StockAnalysis).where( StockAnalysis.stock_code == stock_code, StockAnalysis.analysis_date == analysis_date, ) ) 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, ml_score=result.ml_score, ml_confidence=result.ml_confidence, ml_signal=result.ml_signal, atr_14=result.atr_14, adx_14=result.adx_14, ) session.add(analysis) await session.commit() ml_info = f", ml_score={result.ml_score}" if result.ml_score is not None else "" logger.info(f"✅ Saved analysis for {stock_code} ({stock_name}): score={result.score}{ml_info}") return "ok" except Exception as e: logger.error(f"❌ Failed to analyze {stock_code}: {e}") return "error" async def batch_analyze_all_stocks(limit: int = None): """전체 종목 배치 분석 (동시 처리 + 이중 API 호출 제거로 고속화) 변경 사항: - 순차 처리(+3초 sleep) → asyncio.gather() 동시 처리 (BATCH_CONCURRENCY 종목 병렬) - 이중 API 호출 제거: ATR이 analyze_stock() 내부에서 계산됨 - 각 종목별 독립 세션 사용 (동시 처리 안전) - KIS API 내부 세마포어(API_MAX_CONCURRENT_REQUESTS)가 자체적으로 속도 제어 """ start_time = datetime.now() analysis_date = date.today() # 동시 처리 종목 수 (KIS API 내부 세마포어로 실제 API 호출은 추가 제어됨) BATCH_CONCURRENCY = 5 logger.info(f"🚀 Starting daily batch analysis for {analysis_date}") logger.info(f"⚙️ 동시 처리: {BATCH_CONCURRENCY}종목, API 세마포어: {settings.API_MAX_CONCURRENT_REQUESTS}req") # KIS API 인스턴스 (종목 목록 조회용) kis_api = KoreaInvestmentAPI() # 1. 시가총액 상위 500개 종목 조회 (ETF/특수 종목 자동 제외) market_cap_limit = 500 all_stocks = await get_all_tradable_stocks(kis_api, limit=market_cap_limit) logger.info(f"📊 시가총액 상위 {len(all_stocks)}개 종목 선정 완료") # 테스트용 추가 제한 (옵션) filtered_stocks = all_stocks if limit: filtered_stocks = all_stocks[:limit] logger.info(f"🧪 테스트 모드: {limit}개 종목으로 제한") # 2. 동시 처리 (세마포어로 동시 처리 종목 수 제한) semaphore = asyncio.Semaphore(BATCH_CONCURRENCY) total = len(filtered_stocks) async def analyze_one(i: int, stock_code: str, stock_name: str) -> bool: async with semaphore: logger.info(f"[{i}/{total}] Analyzing {stock_code} ({stock_name})...") return await analyze_and_save_stock(stock_code, stock_name, analysis_date) tasks = [ analyze_one(i, code, name) for i, (code, name) in enumerate(filtered_stocks, 1) ] results = await asyncio.gather(*tasks, return_exceptions=True) success_count = sum(1 for r in results if r == "ok") no_chart_count = sum(1 for r in results if r == "no_chart") price_filter_count = sum(1 for r in results if r == "price_filter") error_count = sum(1 for r in results if r == "error" or isinstance(r, Exception)) # 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_count} / 전체: {len(all_stocks)}") logger.info(f" ├─ 차트없음: {no_chart_count}건 (거래정지·신규상장·특수종목)") logger.info(f" ├─ 가격필터: {price_filter_count}건 (5,000원 미만)") logger.info(f" └─ 오류: {error_count}건") logger.info(f"⏱️ Duration: {duration:.1f} seconds ({duration/60:.1f} minutes)") logger.info(f"📅 Analysis Date: {analysis_date}") # API 통계 출력 (싱글톤 인스턴스 기준) stats = korea_investment_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) # 분석종목(watchlist) 초기화 — score 상위 50개로 장중 미니배치 대상 설정 try: from app.services.watchlist import initialize_watchlist async with AsyncSessionLocal() as session: count = await initialize_watchlist(session, analysis_date) logger.info(f"📋 분석종목 초기화 완료: {count}개 → 장중 미니배치 준비") except Exception as e: logger.warning(f"⚠️ 분석종목 초기화 실패 (non-critical): {e}") 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())