improve: batch pipeline - market regime detection + DB scanner + watchlist priority

This commit is contained in:
2026-02-22 17:46:06 +09:00
parent 0fe933c480
commit 357504a61d
2 changed files with 304 additions and 150 deletions

View File

@@ -3,8 +3,14 @@
아침 배치(batch_daily_analysis)와 장중 배치(batch_intraday_analysis)가 공유하는 아침 배치(batch_daily_analysis)와 장중 배치(batch_intraday_analysis)가 공유하는
초기화 함수. 각 배치 스크립트가 자신의 AsyncSession을 전달해 사용한다. 초기화 함수. 각 배치 스크립트가 자신의 AsyncSession을 전달해 사용한다.
[승률 개선]
- scanner_tickers가 제공되면 해당 종목을 워치리스트 앞쪽에 우선 배치
- 나머지 슬롯은 score 상위 순으로 채움
- added_reason: "scanner_signal" | "morning_batch" 로 구분되어 UI 에서도 식별 가능
""" """
from datetime import datetime, date from datetime import datetime, date
from typing import Optional
import logging import logging
from sqlalchemy import select, update from sqlalchemy import select, update
@@ -15,20 +21,38 @@ from app.models.models import WatchlistStock, StockAnalysis
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 분석종목 관련 상수 # 분석종목 관련 상수
WATCHLIST_INIT_SIZE: int = 50 # 아침 배치 초기화 종목 수 WATCHLIST_INIT_SIZE: int = 50 # 아침 배치 초기화 종목 수
WATCHLIST_MAX_SIZE: int = 100 # 최대 관리 종목 수 WATCHLIST_MAX_SIZE: int = 100 # 최대 관리 종목 수
WATCHLIST_ENTRY_SCORE: int = 60 # 장중 신규 진입 기준 점수 WATCHLIST_ENTRY_SCORE: int = 60 # 장중 신규 진입 기준 점수
WATCHLIST_EXIT_SCORE: int = 20 # 장중 제거 기준 점수 WATCHLIST_EXIT_SCORE: int = 20 # 장중 제거 기준 점수
# 스캐너 신호 종목에 배정하는 최대 슬롯 수 (WATCHLIST_INIT_SIZE 중 이 수만큼 우선)
SCANNER_PRIORITY_SLOTS: int = 20
async def initialize_watchlist(session: AsyncSession, analysis_date: date) -> int: async def initialize_watchlist(
session: AsyncSession,
analysis_date: date,
scanner_tickers: Optional[set[str]] = None,
) -> int:
"""아침 배치 완료 후 분석종목 초기화. """아침 배치 완료 후 분석종목 초기화.
기존 활성 종목을 전부 비활성화하고, 워치리스트 구성 우선순위:
오늘 stock_analysis score 상위 WATCHLIST_INIT_SIZE 개로 새로 채운다. 1순위: scanner_tickers ∩ score 상위에 동시 포함된 종목 (added_reason="scanner_signal")
2순위: scanner_tickers에 있지만 score 랭킹 밖 종목 (added_reason="scanner_signal")
3순위: score 상위 에서 남은 슬롯 커버 (added_reason="morning_batch")
scanner_tickers는 SCANNER_PRIORITY_SLOTS(20)개까지만 우선 배치하여
스캐너 도미노 현상을 방지한다.
Args:
session: AsyncSession
analysis_date: 오늘 일자
scanner_tickers: DB 스캐너에서 추출된 신호 종목 코드 set
Returns: 추가된 종목 수 Returns: 추가된 종목 수
""" """
scanner_tickers = scanner_tickers or set()
now = datetime.now() now = datetime.now()
# 1. 기존 활성 종목 전부 비활성화 # 1. 기존 활성 종목 전부 비활성화
@@ -46,18 +70,74 @@ async def initialize_watchlist(session: AsyncSession, analysis_date: date) -> in
.limit(WATCHLIST_INIT_SIZE) .limit(WATCHLIST_INIT_SIZE)
) )
top_stocks = result.all() top_stocks = result.all()
top_codes = {row.stock_code for row in top_stocks}
# 3. 신규 활성 종목 등록 added = 0
added_codes: set[str] = set()
scanner_slots = SCANNER_PRIORITY_SLOTS
# --- 1순위: score 상위 + 스캐너 동시 포함 종목 (스캐너 신호로 마크) ---
for row in top_stocks: for row in top_stocks:
if row.stock_code not in scanner_tickers:
continue
if scanner_slots <= 0:
break
session.add(WatchlistStock( session.add(WatchlistStock(
stock_code=row.stock_code, stock_code=row.stock_code, stock_name=row.stock_name,
stock_name=row.stock_name, added_at=now, added_reason="scanner_signal",
added_at=now, score_at_add=row.score, is_active=True,
added_reason="morning_batch",
score_at_add=row.score,
is_active=True,
)) ))
added_codes.add(row.stock_code)
scanner_slots -= 1
added += 1
# --- 2순위: 스캐너에만 있고 score 랭킹에는 없는 종목 (최대 scanner_slots개) ---
extra = list(scanner_tickers - top_codes)
if extra and scanner_slots > 0:
extra_result = await session.execute(
select(StockAnalysis.stock_code, StockAnalysis.stock_name, StockAnalysis.score)
.where(
StockAnalysis.analysis_date == analysis_date,
StockAnalysis.stock_code.in_(extra),
)
.order_by(StockAnalysis.score.desc())
.limit(scanner_slots)
)
for row in extra_result.all():
if row.stock_code in added_codes:
continue
session.add(WatchlistStock(
stock_code=row.stock_code, stock_name=row.stock_name,
added_at=now, added_reason="scanner_signal",
score_at_add=row.score, is_active=True,
))
added_codes.add(row.stock_code)
added += 1
# --- 3순위: 나머지 슬롯 — score 상위 순 ---
remaining = WATCHLIST_INIT_SIZE - added
for row in top_stocks:
if remaining <= 0:
break
if row.stock_code in added_codes:
continue
session.add(WatchlistStock(
stock_code=row.stock_code, stock_name=row.stock_name,
added_at=now, added_reason="morning_batch",
score_at_add=row.score, is_active=True,
))
added_codes.add(row.stock_code)
remaining -= 1
added += 1
await session.commit() await session.commit()
logger.info(f"📋 분석종목 초기화 완료: {len(top_stocks)}개 (기준일: {analysis_date})")
return len(top_stocks) scanner_added = added - remaining if remaining >= 0 else added
logger.info(
"📋 워치리스트 초기화: %d개 (scanner_signal=%d, morning_batch=%d, 기준일: %s)",
added,
len([c for c in added_codes]), # 로깅용 (simplified)
remaining if remaining >= 0 else 0,
analysis_date,
)
return added

View File

@@ -2,23 +2,24 @@
매일 아침 장 시작 전(9시) 실행되는 배치 분석 스크립트 매일 아침 장 시작 전(9시) 실행되는 배치 분석 스크립트
전체 거래 종목을 분석하여 DB에 저장 전체 거래 종목을 분석하여 DB에 저장
- 차트 데이터 조회 1. 시장 레짐 판별 (KOSPI200 기준 BULL/BEAR/SIDEWAYS)
- 기술적 지표 계산 2. 차트 데이터 조회 + 기술적 지표 계산
- 매매 신호 생성 3. 매매 신호 생성 + DB 저장
- DB 저장 4. 배치 완료 후 DB 스캐너 실행 (추가 API 호출 없이)
5. 워치리스트 초기화 — 스캐너 신호 종목 우선 배치
실행: python batch_daily_analysis.py 실행: python batch_daily_analysis.py
Cron: 0 9 * * 1-5 cd /path/to/auto-stock/backend && python batch_daily_analysis.py Cron: 0 9 * * 1-5 cd /path/to/auto-stock/backend && python batch_daily_analysis.py
""" """
import asyncio import asyncio
import sys import sys
from datetime import datetime, date from datetime import datetime, date, timedelta
from pathlib import Path from pathlib import Path
# 프로젝트 루트를 Python 경로에 추가
sys.path.insert(0, str(Path(__file__).parent)) sys.path.insert(0, str(Path(__file__).parent))
from sqlalchemy import delete from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
@@ -34,9 +35,9 @@ logging.basicConfig(
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# -------------------------------------------------------------------
# 데이터베이스 연결 # DB 연결
# SQLAlchemy asyncpg 드라이버 사용 (postgresql+asyncpg://) # -------------------------------------------------------------------
database_url = settings.DATABASE_URL database_url = settings.DATABASE_URL
if database_url.startswith("postgresql://"): if database_url.startswith("postgresql://"):
database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1) database_url = database_url.replace("postgresql://", "postgresql+asyncpg://", 1)
@@ -56,106 +57,119 @@ AsyncSessionLocal = sessionmaker(
) )
# -------------------------------------------------------------------
# 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]]: async def get_all_tradable_stocks(kis_api: KoreaInvestmentAPI, limit: int = 500) -> list[tuple[str, str]]:
"""시가총액 상위 종목 조회 (pykrx 사용) """시가총액 상위 종목 조회 (pykrx 사용)
Args: ETF / 레버리지 / 스팭 등 특수종목 자동 제외.
kis_api: KIS API 인스턴스 (호환성 유지용, 미사용) KOSPI + KOSDAQ 합산 시갌총액 상위 limit개 반환.
limit: 조회할 종목 수 (기본 500개)
Returns:
[(stock_code, stock_name), ...] 시가총액 상위 종목 리스트
""" """
logger.info(f"Fetching top {limit} stocks by market cap from pykrx...") logger.info("Fetching top %d stocks by market cap from pykrx...", limit)
try: try:
from pykrx import stock as pykrx_stock from pykrx import stock as pykrx_stock
import pandas as pd
today = datetime.now().strftime("%Y%m%d") today = datetime.now().strftime("%Y%m%d")
stocks_with_cap = []
# ETF/특수 종목 제외 키워드
SKIP_KEYWORDS = [ SKIP_KEYWORDS = [
"ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG", "ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG",
"KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX", "KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX",
"선물", "스팩", "리츠", "SPAC", "선물", "스팭", "리츠", "SPAC",
] ]
# KOSPI + KOSDAQ 전체 종목 조회 stocks_with_cap = []
for market in ["KOSPI", "KOSDAQ"]: for market in ["KOSPI", "KOSDAQ"]:
try: try:
# 시장 전체의 시가총액 데이터를 한 번에 조회 (최적화)
cap_df = pykrx_stock.get_market_cap_by_ticker(today, market=market) cap_df = pykrx_stock.get_market_cap_by_ticker(today, market=market)
logger.info(f" {market}: {len(cap_df)}개 종목 시가총액 조회") logger.info(" %s: %d개 종목 시가총액 조회", market, len(cap_df))
for ticker in cap_df.index: for ticker in cap_df.index:
try: try:
name = pykrx_stock.get_market_ticker_name(ticker) name = pykrx_stock.get_market_ticker_name(ticker)
# ETF/특수 종목 필터링
if any(k in name for k in SKIP_KEYWORDS): if any(k in name for k in SKIP_KEYWORDS):
continue continue
stocks_with_cap.append((ticker, name, cap_df.loc[ticker, "시가총액"]))
market_cap = cap_df.loc[ticker, "시가총액"] except Exception:
stocks_with_cap.append((ticker, name, market_cap))
except Exception as e:
logger.debug(f" Skip {ticker}: {e}")
continue continue
except Exception as exc:
logger.error("❌ %s 종목 조회 실패: %s", market, exc)
except Exception as e:
logger.error(f"❌ Failed to fetch {market} stocks: {e}")
# 시가총액 기준 내림차순 정렬
stocks_with_cap.sort(key=lambda x: x[2], reverse=True) stocks_with_cap.sort(key=lambda x: x[2], reverse=True)
# 상위 N개 선택 (시가총액 제외)
top_stocks = [(code, name) for code, name, _ in stocks_with_cap[:limit]] top_stocks = [(code, name) for code, name, _ in stocks_with_cap[:limit]]
logger.info(f"✅ Selected top {len(top_stocks)} stocks by market cap") kospi_n = sum(1 for c, _ in top_stocks if c.startswith(('0','1','2','3','4','5')))
if top_stocks: kosdaq_n = len(top_stocks) - kospi_n
kospi_count = sum(1 for c, _ in top_stocks if c.startswith(('0', '1', '2', '3', '4', '5'))) logger.info("✅ 선정 %d개 (KOSPI %d / KOSDAQ %d)", len(top_stocks), kospi_n, kosdaq_n)
kosdaq_count = len(top_stocks) - kospi_count
logger.info(f" KOSPI: {kospi_count} stocks")
logger.info(f" KOSDAQ: {kosdaq_count} stocks")
return top_stocks return top_stocks
except Exception as e: except Exception as exc:
logger.error(f"❌ Failed to fetch stocks by market cap: {e}", exc_info=True) logger.error("❌ 종목 목록 조회 실패: %s", exc, exc_info=True)
logger.warning("⚠️ Falling back to empty list")
return [] return []
# -------------------------------------------------------------------
# 3. 단일 종목 분석 + DB 저장
# -------------------------------------------------------------------
async def analyze_and_save_stock( async def analyze_and_save_stock(
stock_code: str, stock_code: str,
stock_name: str, stock_name: str,
analysis_date: datetime analysis_date: datetime
) -> str: ) -> str:
"""종목 분석 후 DB 저장 (독립 세션 사용 — 동시 처리 안전) """종목 분석 후 DB 저장 (독립 세션 사용)
ATR은 analyze_stock() 내부에서 이미 계산되어 result.atr_14에 포함됨.
이중 API 호출 없음.
Returns: "ok" | "no_chart" | "price_filter" | "error" Returns: "ok" | "no_chart" | "price_filter" | "error"
""" """
try: try:
# 배치 전용 함수 사용 (거래량/점수 필터 없이 전 종목 지표 저장)
result = await analyze_stock_for_batch(stock_code, stock_name) result = await analyze_stock_for_batch(stock_code, stock_name)
if not result: if not result:
# for_batch=True 에서 None = 차트 데이터 없음 or 가격 0 등 실제 오류 logger.info("샘 차트없음 %s (%s)", stock_code, stock_name)
logger.info(f"차트없음 {stock_code} ({stock_name}): KIS API 차트 데이터 없음")
return "no_chart" return "no_chart"
# 5,000원 이하 저가주 제외 (CLAUDE.md 6.4) # 5,000원 이하 저가주 제외 (CLAUDE.md 6.4)
if result.current_price and result.current_price < 5000: if result.current_price and result.current_price < 5000:
logger.info(f"가격필터 {stock_code} ({stock_name}): {result.current_price:.0f}원 < 5,000원") logger.info("샘 가격필터 %s: %.0f원 < 5,000원", stock_code, result.current_price)
return "price_filter" return "price_filter"
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
# 이미 오늘 분석된 종목이면 삭제 후 재삽입 (중복 방지)
await session.execute( await session.execute(
delete(StockAnalysis).where( delete(StockAnalysis).where(
StockAnalysis.stock_code == stock_code, StockAnalysis.stock_code == stock_code,
@@ -190,115 +204,175 @@ async def analyze_and_save_stock(
await session.commit() await session.commit()
ml_info = f", ml_score={result.ml_score}" if result.ml_score is not None else "" 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}") logger.info("✅ Saved %s (%s): score=%s%s", stock_code, stock_name, result.score, ml_info)
return "ok" return "ok"
except Exception as e:
logger.error(f"❌ Failed to analyze {stock_code}: {e}") except Exception as exc:
logger.error("❌ Failed %s: %s", stock_code, exc)
return "error" return "error"
async def batch_analyze_all_stocks(limit: int = None): # -------------------------------------------------------------------
"""전체 종목 배치 분석 (동시 처리 + 이중 API 호출 제거로 고속화) # 4. DB 스캐너 — 배치 완료 후 추가 API 호출 없이 신호 종목 추출
# -------------------------------------------------------------------
변경 사항: async def run_db_scanner(analysis_date: date) -> set[str]:
- 순차 처리(+3초 sleep) → asyncio.gather() 동시 처리 (BATCH_CONCURRENCY 종목 병렬) """배치 DB 기반 스캐너 — KosdaqScanner와 동일한 기준을 단일일 데이터로 적용.
- 이중 API 호출 제거: ATR이 analyze_stock() 내부에서 계산됨
- 각 종목별 독립 세션 사용 (동시 처리 안전) 저장된 RSI / 볼린저 / 거래량 데이터만 사용하므로 추가 API 호출 없음.
- KIS API 내부 세마포어(API_MAX_CONCURRENT_REQUESTS)가 자체적으로 속도 제어
조건 (성능 중심 필터):
- RSI(14) ≤ 30 (과매도)
- 종가 ≤ BB 하단 × 1.015 (BB 하단 근체)
- 시세 ≥ 5,000원
- ADX ≤ 30 (지나치게 강한 추세는 평균회귀 부적합)
Returns: 신호 종목 코드 set
""" """
start_time = datetime.now() try:
analysis_date = date.today() 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()
# 동시 처리 종목 수 (KIS API 내부 세마포어로 실제 API 호출은 추가 제어됨) 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 BATCH_CONCURRENCY = 5
logger.info(f"🚀 Starting daily batch analysis for {analysis_date}") logger.info("🚀 Starting daily batch analysis for %s", analysis_date)
logger.info(f"⚙️ 동시 처리: {BATCH_CONCURRENCY}종목, API 세마포어: {settings.API_MAX_CONCURRENT_REQUESTS}req") logger.info("⚙️ 동시 처리: %d종목, API 세마포어: %dreq",
BATCH_CONCURRENCY, settings.API_MAX_CONCURRENT_REQUESTS)
# KIS API 인스턴스 (종목 목록 조회용) # Step 1: 시장 레짐 판별
regime = await detect_and_log_market_regime()
# Step 2: 종목 목록 조회
kis_api = KoreaInvestmentAPI() kis_api = KoreaInvestmentAPI()
all_stocks = await get_all_tradable_stocks(kis_api, limit=500)
# 1. 시가총액 상위 500개 종목 조회 (ETF/특수 종목 자동 제외) filtered_stocks = all_stocks[:limit] if limit else all_stocks
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) total = len(filtered_stocks)
logger.info("📊 분석 대상: %d개 (시장 레짐: %s)", total, regime)
async def analyze_one(i: int, stock_code: str, stock_name: str) -> bool: # Step 3: 동시 분석 + DB 저장
semaphore = asyncio.Semaphore(BATCH_CONCURRENCY)
async def analyze_one(i: int, code: str, name: str) -> str:
async with semaphore: async with semaphore:
logger.info(f"[{i}/{total}] Analyzing {stock_code} ({stock_name})...") logger.info("[%d/%d] %s (%s)", i, total, code, name)
return await analyze_and_save_stock(stock_code, stock_name, analysis_date) return await analyze_and_save_stock(code, name, analysis_date)
tasks = [ tasks = [analyze_one(i, c, n) for i, (c, n) in enumerate(filtered_stocks, 1)]
analyze_one(i, code, name)
for i, (code, name) in enumerate(filtered_stocks, 1)
]
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
success_count = sum(1 for r in results if r == "ok") ok_count = sum(1 for r in results if r == "ok")
no_chart_count = sum(1 for r in results if r == "no_chart") no_chart = sum(1 for r in results if r == "no_chart")
price_filter_count = sum(1 for r in results if r == "price_filter") price_flt = 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)) err_count = sum(1 for r in results if r == "error" or isinstance(r, Exception))
# 4. 결과 요약 및 최종 통계 # Step 4: DB 스캐너 (배치 완료 후 추가 API 호출 없이)
end_time = datetime.now() scanner_tickers = await run_db_scanner(analysis_date)
duration = (end_time - start_time).total_seconds()
logger.info("=" * 60) # Step 5: 워치리스트 초기화 — 스캐너 신호 종목 우선
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: try:
from app.services.watchlist import initialize_watchlist from app.services.watchlist import initialize_watchlist
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
count = await initialize_watchlist(session, analysis_date) count = await initialize_watchlist(
logger.info(f"📋 분석종목 초기화 완료: {count}개 → 장중 미니배치 준비") session, analysis_date, scanner_tickers=scanner_tickers
except Exception as e: )
logger.warning(f"⚠️ 분석종목 초기화 실패 (non-critical): {e}") 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(): async def main():
"""메인 실행 함수"""
import argparse import argparse
parser = argparse.ArgumentParser(description='Daily Stock Analysis Batch') parser = argparse.ArgumentParser(description='Daily Stock Analysis Batch')
parser.add_argument('--limit', type=int, help='Limit number of stocks (for testing)') 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)') parser.add_argument('--test', action='store_true', help='Test mode (10 stocks)')
args = parser.parse_args() args = parser.parse_args()
if args.test: if args.test:
logger.info("🧪 TEST MODE: Analyzing 10 stocks only") logger.info("🧪 TEST MODE: 10개 종목")
await batch_analyze_all_stocks(limit=10) await batch_analyze_all_stocks(limit=10)
elif args.limit: elif args.limit:
logger.info(f"📊 Custom limit: {args.limit} stocks")
await batch_analyze_all_stocks(limit=args.limit) await batch_analyze_all_stocks(limit=args.limit)
else: else:
logger.info("📊 Full analysis: All stocks")
await batch_analyze_all_stocks() await batch_analyze_all_stocks()