Files
auto-stock/backend/batch_daily_analysis.py

381 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
매일 아침 장 시작 전(9시) 실행되는 배치 분석 스크립트
전체 거래 종목을 분석하여 DB에 저장
1. 시장 레짐 판별 (KOSPI200 기준 BULL/BEAR/SIDEWAYS)
2. 차트 데이터 조회 + 기술적 지표 계산
3. 매매 신호 생성 + DB 저장
4. 배치 완료 후 DB 스캐너 실행 (추가 API 호출 없이)
5. 워치리스트 초기화 — 스캐너 신호 종목 우선 배치
실행: 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, timedelta
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from sqlalchemy import delete, select
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__)
# -------------------------------------------------------------------
# DB 연결
# -------------------------------------------------------------------
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
)
# -------------------------------------------------------------------
# 1. 시장 레짐 판별 (KOSPI 기준)
# -------------------------------------------------------------------
async def detect_and_log_market_regime() -> str:
"""배치 시작 시 KOSPI 200일 데이터로 시장 레짐 판별.
BULL / BEAR / SIDEWAYS 중 하나를 리턴하며 배치 전체에 로깅된다.
실패 시 UNKNOWN을 리턴하여 배치를 차단하지 않는다.
"""
try:
from pykrx import stock as pykrx_stock
from app.services.mean_reversion_strategy import detect_market_regime
today = datetime.now().strftime("%Y%m%d")
start = (datetime.now() - timedelta(days=400)).strftime("%Y%m%d")
# KOSPI 지수 코드 1001
df = pykrx_stock.get_index_ohlcv_by_date(start, today, "1001")
if df is None or len(df) < 205:
logger.warning("⚠️ KOSPI 데이터 부족(열 %d) — 레짐 판별 스킵", 0 if df is None else len(df))
return "UNKNOWN"
kospi_closes = df["종가"].tolist()
regime = detect_market_regime(kospi_closes)
regime_emoji = {"승장": "📈 BULL", "하락장": "📉 BEAR",
"bull": "📈 BULL", "bear": "📉 BEAR"}.get(regime.value, "➡️ " + regime.value.upper())
logger.info("🌏 시장 레짐: %s (KOSPI %s일 기준)", regime_emoji, today)
return regime.value.upper()
except Exception as exc:
logger.error("❌ 레짐 판별 실패: %s", exc, exc_info=True)
return "UNKNOWN"
# -------------------------------------------------------------------
# 2. 종목 목록 조회
# -------------------------------------------------------------------
async def get_all_tradable_stocks(kis_api: KoreaInvestmentAPI, limit: int = 500) -> list[tuple[str, str]]:
"""시가총액 상위 종목 조회 (pykrx 사용)
ETF / 레버리지 / 스팭 등 특수종목 자동 제외.
KOSPI + KOSDAQ 합산 시갌총액 상위 limit개 반환.
"""
logger.info("Fetching top %d stocks by market cap from pykrx...", limit)
try:
from pykrx import stock as pykrx_stock
today = datetime.now().strftime("%Y%m%d")
SKIP_KEYWORDS = [
"ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG",
"KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX",
"선물", "스팭", "리츠", "SPAC",
]
stocks_with_cap = []
for market in ["KOSPI", "KOSDAQ"]:
try:
cap_df = pykrx_stock.get_market_cap_by_ticker(today, market=market)
logger.info(" %s: %d개 종목 시가총액 조회", market, len(cap_df))
for ticker in cap_df.index:
try:
name = pykrx_stock.get_market_ticker_name(ticker)
if any(k in name for k in SKIP_KEYWORDS):
continue
stocks_with_cap.append((ticker, name, cap_df.loc[ticker, "시가총액"]))
except Exception:
continue
except Exception as exc:
logger.error("❌ %s 종목 조회 실패: %s", market, exc)
stocks_with_cap.sort(key=lambda x: x[2], reverse=True)
top_stocks = [(code, name) for code, name, _ in stocks_with_cap[:limit]]
kospi_n = sum(1 for c, _ in top_stocks if c.startswith(('0','1','2','3','4','5')))
kosdaq_n = len(top_stocks) - kospi_n
logger.info("✅ 선정 %d개 (KOSPI %d / KOSDAQ %d)", len(top_stocks), kospi_n, kosdaq_n)
return top_stocks
except Exception as exc:
logger.error("❌ 종목 목록 조회 실패: %s", exc, exc_info=True)
return []
# -------------------------------------------------------------------
# 3. 단일 종목 분석 + DB 저장
# -------------------------------------------------------------------
async def analyze_and_save_stock(
stock_code: str,
stock_name: str,
analysis_date: datetime
) -> str:
"""종목 분석 후 DB 저장 (독립 세션 사용)
Returns: "ok" | "no_chart" | "price_filter" | "error"
"""
try:
result = await analyze_stock_for_batch(stock_code, stock_name)
if not result:
logger.info("샘 차트없음 %s (%s)", stock_code, stock_name)
return "no_chart"
# 5,000원 이하 저가주 제외 (CLAUDE.md 6.4)
if result.current_price and result.current_price < 5000:
logger.info("샘 가격필터 %s: %.0f원 < 5,000원", stock_code, result.current_price)
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("✅ Saved %s (%s): score=%s%s", stock_code, stock_name, result.score, ml_info)
return "ok"
except Exception as exc:
logger.error("❌ Failed %s: %s", stock_code, exc)
return "error"
# -------------------------------------------------------------------
# 4. DB 스캐너 — 배치 완료 후 추가 API 호출 없이 신호 종목 추출
# -------------------------------------------------------------------
async def run_db_scanner(analysis_date: date) -> set[str]:
"""배치 DB 기반 스캐너 — KosdaqScanner와 동일한 기준을 단일일 데이터로 적용.
저장된 RSI / 볼린저 / 거래량 데이터만 사용하므로 추가 API 호출 없음.
조건 (성능 중심 필터):
- RSI(14) ≤ 30 (과매도)
- 종가 ≤ BB 하단 × 1.015 (BB 하단 근체)
- 시세 ≥ 5,000원
- ADX ≤ 30 (지나치게 강한 추세는 평균회귀 부적합)
Returns: 신호 종목 코드 set
"""
try:
async with AsyncSessionLocal() as session:
result = await session.execute(
select(
StockAnalysis.stock_code,
StockAnalysis.current_price,
StockAnalysis.rsi,
StockAnalysis.bollinger_lower,
StockAnalysis.adx_14,
).where(
StockAnalysis.analysis_date == analysis_date,
StockAnalysis.current_price >= 5000,
StockAnalysis.rsi.isnot(None),
StockAnalysis.rsi <= 30,
StockAnalysis.bollinger_lower.isnot(None),
)
)
rows = result.all()
signal_tickers: set[str] = set()
for row in rows:
price, bb_lower = row.current_price, row.bollinger_lower
if not bb_lower or bb_lower == 0:
continue
# BB 하단선 근처 확인 (-inf ~ +1.5%)
pct = (price - bb_lower) / bb_lower * 100
if pct > 1.5:
continue
# 구조적 추세장(ADX > 30)는 평균회귀에 부적합
adx = row.adx_14
if adx is not None and adx > 30:
continue
signal_tickers.add(row.stock_code)
logger.info("🔍 DB 스캐너: %d개 신호 종목 (RSI≤30 + BB하단 + ADX≤30)", len(signal_tickers))
return signal_tickers
except Exception as exc:
logger.error("❌ DB 스캐너 실패: %s", exc, exc_info=True)
return set()
# -------------------------------------------------------------------
# 5. 메인 배치
# -------------------------------------------------------------------
async def batch_analyze_all_stocks(limit: int = None):
"""전체 종목 배치 분석
실행 순서:
1. 시장 레짐 판별 (KOSPI 지수 기준)
2. 시가총액 상위 500개 종목 조회
3. asyncio.gather() 동시 분석 + DB 저장
4. DB 스캐너 실행 (추가 API 호출 없음)
5. 워치리스트 초기화 — 스캐너 신호 종목 우선
"""
start_time = datetime.now()
analysis_date = date.today()
BATCH_CONCURRENCY = 5
logger.info("🚀 Starting daily batch analysis for %s", analysis_date)
logger.info("⚙️ 동시 처리: %d종목, API 세마포어: %dreq",
BATCH_CONCURRENCY, settings.API_MAX_CONCURRENT_REQUESTS)
# Step 1: 시장 레짐 판별
regime = await detect_and_log_market_regime()
# Step 2: 종목 목록 조회
kis_api = KoreaInvestmentAPI()
all_stocks = await get_all_tradable_stocks(kis_api, limit=500)
filtered_stocks = all_stocks[:limit] if limit else all_stocks
total = len(filtered_stocks)
logger.info("📊 분석 대상: %d개 (시장 레짐: %s)", total, regime)
# Step 3: 동시 분석 + DB 저장
semaphore = asyncio.Semaphore(BATCH_CONCURRENCY)
async def analyze_one(i: int, code: str, name: str) -> str:
async with semaphore:
logger.info("[%d/%d] %s (%s)", i, total, code, name)
return await analyze_and_save_stock(code, name, analysis_date)
tasks = [analyze_one(i, c, n) for i, (c, n) in enumerate(filtered_stocks, 1)]
results = await asyncio.gather(*tasks, return_exceptions=True)
ok_count = sum(1 for r in results if r == "ok")
no_chart = sum(1 for r in results if r == "no_chart")
price_flt = sum(1 for r in results if r == "price_filter")
err_count = sum(1 for r in results if r == "error" or isinstance(r, Exception))
# Step 4: DB 스캐너 (배치 완료 후 추가 API 호출 없이)
scanner_tickers = await run_db_scanner(analysis_date)
# Step 5: 워치리스트 초기화 — 스캐너 신호 종목 우선
try:
from app.services.watchlist import initialize_watchlist
async with AsyncSessionLocal() as session:
count = await initialize_watchlist(
session, analysis_date, scanner_tickers=scanner_tickers
)
logger.info("📋 워치리스트 초기화 완료: %d개 (스캐너 신호 %d개 포함)",
count, len(scanner_tickers))
except Exception as exc:
logger.warning("⚠️ 워치리스트 초기화 실패 (non-critical): %s", exc)
# 결과 요약
duration = (datetime.now() - start_time).total_seconds()
stats = korea_investment_api.get_monitor_stats()
logger.info("=" * 60)
logger.info("✅ Batch analysis completed!")
logger.info("🌏 시장 레짐: %s", regime)
logger.info("📊 성공: %d / 전체: %d", ok_count, total)
logger.info(" ├─ 차트없음: %d건", no_chart)
logger.info(" ├─ 가격필터: %d건", price_flt)
logger.info(" ├─ 오류: %d건", err_count)
logger.info("🔍 DB 스캐너 신호: %d개", len(scanner_tickers))
logger.info("⏱️ %.1f초 (%.1f분)", duration, duration / 60)
logger.info("📡 API 통계 — 호출:%d 오류:%d(%.1f%%) 평균:%.2f초",
stats['total_calls'], stats['total_errors'],
stats['error_rate'] * 100, stats['avg_duration'])
logger.info("=" * 60)
# -------------------------------------------------------------------
# Entry Point
# -------------------------------------------------------------------
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 (10 stocks)')
args = parser.parse_args()
if args.test:
logger.info("🧪 TEST MODE: 10개 종목")
await batch_analyze_all_stocks(limit=10)
elif args.limit:
await batch_analyze_all_stocks(limit=args.limit)
else:
await batch_analyze_all_stocks()
if __name__ == "__main__":
asyncio.run(main())