381 lines
15 KiB
Python
381 lines
15 KiB
Python
"""
|
||
매일 아침 장 시작 전(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())
|