수정본
This commit is contained in:
@@ -6,7 +6,6 @@
|
||||
- 기술적 지표 계산
|
||||
- 매매 신호 생성
|
||||
- DB 저장
|
||||
|
||||
실행: python batch_daily_analysis.py
|
||||
Cron: 0 9 * * 1-5 cd /path/to/auto-stock/backend && python batch_daily_analysis.py
|
||||
"""
|
||||
@@ -19,14 +18,14 @@ from pathlib import Path
|
||||
# 프로젝트 루트를 Python 경로에 추가
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from sqlalchemy import select, delete
|
||||
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
|
||||
from app.api.endpoints.recommendations import analyze_stock_with_indicators
|
||||
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(
|
||||
@@ -37,8 +36,13 @@ 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(
|
||||
settings.DATABASE_URL,
|
||||
database_url,
|
||||
echo=False,
|
||||
pool_pre_ping=True,
|
||||
pool_size=10,
|
||||
@@ -52,159 +56,214 @@ AsyncSessionLocal = sessionmaker(
|
||||
)
|
||||
|
||||
|
||||
async def get_all_tradable_stocks() -> list[tuple[str, str]]:
|
||||
"""거래 가능한 전체 종목 조회 (안전한 재시도 적용)"""
|
||||
kis_api = KoreaInvestmentAPI()
|
||||
|
||||
logger.info("Fetching all tradable stocks from KIS API...")
|
||||
|
||||
stocks = []
|
||||
|
||||
# KOSPI 상위 종목 조회
|
||||
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:
|
||||
# 재시도 로직이 내장되어 있으므로 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")
|
||||
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 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
|
||||
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(
|
||||
session: AsyncSession,
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
analysis_date: datetime
|
||||
) -> bool:
|
||||
"""종목 분석 후 DB 저장"""
|
||||
) -> str:
|
||||
"""종목 분석 후 DB 저장 (독립 세션 사용 — 동시 처리 안전)
|
||||
|
||||
ATR은 analyze_stock() 내부에서 이미 계산되어 result.atr_14에 포함됨.
|
||||
이중 API 호출 없음.
|
||||
|
||||
Returns: "ok" | "no_chart" | "price_filter" | "error"
|
||||
"""
|
||||
try:
|
||||
# 종목 분석 (기존 함수 재사용)
|
||||
result = await analyze_stock_with_indicators(stock_code, stock_name)
|
||||
|
||||
# 배치 전용 함수 사용 (거래량/점수 필터 없이 전 종목 지표 저장)
|
||||
result = await analyze_stock_for_batch(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
|
||||
|
||||
# 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}")
|
||||
await session.rollback()
|
||||
return False
|
||||
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"⚙️ Rate Limiting: {settings.API_MIN_REQUEST_INTERVAL}초 간격, 최대 재시도 {settings.API_MAX_RETRIES}회")
|
||||
|
||||
# KIS API 인스턴스
|
||||
logger.info(f"⚙️ 동시 처리: {BATCH_CONCURRENCY}종목, API 세마포어: {settings.API_MAX_CONCURRENT_REQUESTS}req")
|
||||
|
||||
# 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()
|
||||
|
||||
|
||||
# 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: {success_count} / Total: {len(all_stocks)}")
|
||||
logger.info(f"❌ Failed: {fail_count}")
|
||||
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 = kis_api.get_monitor_stats()
|
||||
|
||||
# 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%})")
|
||||
@@ -212,6 +271,15 @@ async def batch_analyze_all_stocks(limit: int = None):
|
||||
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():
|
||||
"""메인 실행 함수"""
|
||||
|
||||
Reference in New Issue
Block a user