구조적인 부분 수정
This commit is contained in:
@@ -4,14 +4,15 @@
|
||||
전체 거래 종목을 분석하여 DB에 저장
|
||||
1. 시장 레짐 판별 (KOSPI200 기준 BULL/BEAR/SIDEWAYS)
|
||||
2. 차트 데이터 조회 + 기술적 지표 계산
|
||||
3. 매매 신호 생성 + DB 일괄 저장 (BULK INSERT)
|
||||
3. 매매 신호 생성 + DB Streaming 저장 (분석 즉시 UPSERT)
|
||||
4. 배치 완료 후 DB 스캐너 실행 (추가 API 호출 없이)
|
||||
5. 워치리스트 초기화 — 스캐너 신호 종목 우선 배치
|
||||
|
||||
성능 개선 (이전 대비):
|
||||
- BATCH_CONCURRENCY: 5 → 10 (API 세마포어 허용 범위 내)
|
||||
- DB 트랜잭션: 종목당 2개(DELETE+INSERT) → BULK_SAVE_CHUNK(20개)당 1개
|
||||
- 500종목 기준: 1,000 트랜잭션 → 25 트랜잭션 (40× 감소)
|
||||
- DB 트랜잭션: 종목당 2개(DELETE+INSERT) → UPSERT (원자적, race-condition 없음)
|
||||
- Streaming: 전체 메모리 적재 → 분석 완료 즉시 BULK_SAVE_CHUNK(20)개 단위 저장
|
||||
- DB 엔진: 스크립트 자체 생성 → app.core.database 공통 모듈 사용
|
||||
|
||||
실행: python batch_daily_analysis.py
|
||||
Cron: 0 9 * * 1-5 cd /path/to/auto-stock/backend && python batch_daily_analysis.py
|
||||
@@ -28,13 +29,10 @@ KST = pytz.timezone('Asia/Seoul')
|
||||
|
||||
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.core.database import AsyncSessionLocal # 🔧 공통 DB 모듈 사용
|
||||
from app.services.korea_investment_api import KoreaInvestmentAPI, korea_investment_api
|
||||
from app.services.analysis_db_service import bulk_upsert_analysis, passes_price_filter, run_db_scanner # 🔧 단일화
|
||||
from app.api.endpoints.recommendations import analyze_stock_for_batch, StockRecommendation
|
||||
import logging
|
||||
|
||||
@@ -44,32 +42,11 @@ logging.basicConfig(
|
||||
)
|
||||
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
|
||||
)
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 성능 설정
|
||||
# -------------------------------------------------------------------
|
||||
BATCH_CONCURRENCY = 10 # ↑ 5→10: API 세마포어 허용 범위 내 동시 처리 수
|
||||
BULK_SAVE_CHUNK = 20 # DB 일괄 저장 단위 (트랜잭션 수 = 총종목 / BULK_SAVE_CHUNK)
|
||||
BATCH_CONCURRENCY = 10 # API 세마포어 허용 범위 내 동시 처리 수
|
||||
BULK_SAVE_CHUNK = 20 # Streaming 저장 단위 (큐에 20개 쌓이면 즉시 UPSERT)
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
@@ -92,13 +69,13 @@ async def detect_and_log_market_regime() -> str:
|
||||
# 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))
|
||||
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",
|
||||
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()
|
||||
@@ -140,17 +117,18 @@ async def get_all_tradable_stocks(kis_api: KoreaInvestmentAPI, limit: int = 500)
|
||||
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, "시가총액"]))
|
||||
stocks_with_cap.append((ticker, name, cap_df.loc[ticker, "시가총액"], market))
|
||||
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]]
|
||||
top_with_mkt = stocks_with_cap[:limit]
|
||||
top_stocks = [(code, name) for code, name, _, _mkt in top_with_mkt]
|
||||
|
||||
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
|
||||
kospi_n = sum(1 for *_, mkt in top_with_mkt if mkt == "KOSPI")
|
||||
kosdaq_n = sum(1 for *_, mkt in top_with_mkt if mkt == "KOSDAQ")
|
||||
logger.info("✅ 선정 %d개 (KOSPI %d / KOSDAQ %d)", len(top_stocks), kospi_n, kosdaq_n)
|
||||
return top_stocks
|
||||
|
||||
@@ -169,7 +147,7 @@ async def analyze_one_stock(
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
) -> tuple[str, Optional[StockRecommendation]]:
|
||||
"""종목 분석만 수행하고 결과 반환. DB 저장은 bulk_save_results에서 일괄 처리.
|
||||
"""종목 분석만 수행하고 결과 반환. DB 저장은 Streaming 저장기에서 처리.
|
||||
|
||||
Returns: (status, result)
|
||||
status: "ok" | "no_chart" | "price_filter" | "error"
|
||||
@@ -182,8 +160,8 @@ async def analyze_one_stock(
|
||||
logger.info("샘 차트없음 %s (%s)", stock_code, stock_name)
|
||||
return "no_chart", None
|
||||
|
||||
# 5,000원 이하 저가주 제외 (CLAUDE.md 6.4)
|
||||
if result.current_price and result.current_price < 5000:
|
||||
# 5,000원 이하 저가주 제외 (CLAUDE.md 5절 가격 필터)
|
||||
if not passes_price_filter(result):
|
||||
logger.info("샘 가격필터 %s: %.0f원 < 5,000원", stock_code, result.current_price)
|
||||
return "price_filter", None
|
||||
|
||||
@@ -197,153 +175,55 @@ async def analyze_one_stock(
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 4. StockRecommendation → StockAnalysis ORM 변환 헬퍼
|
||||
# 4. Streaming 저장기 — 분석 완료 즉시 BULK_SAVE_CHUNK 단위 UPSERT
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
def _to_analysis_model(
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
result: StockRecommendation,
|
||||
async def _streaming_saver(
|
||||
queue: asyncio.Queue,
|
||||
analysis_date: date,
|
||||
) -> StockAnalysis:
|
||||
"""StockRecommendation Pydantic 모델 → StockAnalysis ORM 객체 변환"""
|
||||
return 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,
|
||||
)
|
||||
) -> dict:
|
||||
"""큐에서 분석 결과를 꺼내 BULK_SAVE_CHUNK(20)개마다 즉시 DB UPSERT.
|
||||
|
||||
전체 메모리 적재 없이 분석 완료 즉시 저장하여 메모리 사용 최소화.
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 5. Bulk DB 저장 (BULK_SAVE_CHUNK 개씩 한 트랜잭션)
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
async def bulk_save_results(
|
||||
items: list[tuple[str, str, StockRecommendation]],
|
||||
analysis_date: date,
|
||||
) -> int:
|
||||
"""분석 결과를 한 트랜잭션으로 일괄 저장.
|
||||
|
||||
기존 코드: 종목당 DELETE + INSERT → 2 트랜잭션 × 500종목 = 1,000 트랜잭션
|
||||
개선 코드: IN 쿼리 DELETE 1회 + add_all INSERT 1회 = 2 트랜잭션 × 25청크 = 50 트랜잭션
|
||||
|
||||
items: [(stock_code, stock_name, result), ...]
|
||||
Returns: 저장된 건수
|
||||
Returns: stats dict {"ok", "no_chart", "price_filter", "error", "saved"}
|
||||
"""
|
||||
if not items:
|
||||
return 0
|
||||
buffer: list[tuple[str, str, StockRecommendation]] = []
|
||||
stats = {"ok": 0, "no_chart": 0, "price_filter": 0, "error": 0, "saved": 0}
|
||||
|
||||
codes = [code for code, _, _ in items]
|
||||
while True:
|
||||
item = await queue.get()
|
||||
if item is None: # sentinel: 분석 완료
|
||||
break
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
# 기존 당일 데이터 일괄 삭제 (IN 쿼리 1회)
|
||||
await session.execute(
|
||||
delete(StockAnalysis).where(
|
||||
StockAnalysis.stock_code.in_(codes),
|
||||
StockAnalysis.analysis_date == analysis_date,
|
||||
)
|
||||
)
|
||||
# 일괄 삽입 (add_all → 단일 INSERT 배치)
|
||||
objs = [_to_analysis_model(code, name, r, analysis_date) for code, name, r in items]
|
||||
session.add_all(objs)
|
||||
await session.commit()
|
||||
status, code, name, result = item
|
||||
stats[status] = stats.get(status, 0) + 1
|
||||
|
||||
return len(objs)
|
||||
if status == "ok" and result:
|
||||
buffer.append((code, name, result))
|
||||
|
||||
# 버퍼가 청크 크기에 도달하면 즉시 저장
|
||||
if len(buffer) >= BULK_SAVE_CHUNK:
|
||||
async with AsyncSessionLocal() as session:
|
||||
saved = await bulk_upsert_analysis(session, buffer, analysis_date)
|
||||
await session.commit()
|
||||
stats["saved"] += saved
|
||||
logger.info("💾 DB 저장: %d건 누적", stats["saved"])
|
||||
buffer.clear()
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 6. 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 (지나치게 강한 추세는 평균회귀 부적합)
|
||||
- MA5 ≥ MA20 × 0.95 (ADX 방향 proxy — 강한 하락 추세 종목 제외)
|
||||
|
||||
Returns: 신호 종목 코드 set
|
||||
"""
|
||||
try:
|
||||
# 나머지 잔여분 저장
|
||||
if buffer:
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(
|
||||
select(
|
||||
StockAnalysis.stock_code,
|
||||
StockAnalysis.current_price,
|
||||
StockAnalysis.rsi,
|
||||
StockAnalysis.bollinger_lower,
|
||||
StockAnalysis.adx_14,
|
||||
StockAnalysis.ma5,
|
||||
StockAnalysis.ma20,
|
||||
).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()
|
||||
saved = await bulk_upsert_analysis(session, buffer, analysis_date)
|
||||
await session.commit()
|
||||
stats["saved"] += saved
|
||||
logger.info("💾 DB 저장(잔여): %d건 누적", stats["saved"])
|
||||
|
||||
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
|
||||
|
||||
# ADX 방향 proxy: MA5 < MA20 × 0.95 → 강한 하락 추세 → 평균회귀 부적합
|
||||
# DI+/DI- 컬럼이 없으므로 MA 방향으로 추세 하락 여부 판단
|
||||
ma5, ma20 = row.ma5, row.ma20
|
||||
if ma5 is not None and ma20 is not None and ma20 > 0:
|
||||
if float(ma5) < float(ma20) * 0.95:
|
||||
continue
|
||||
|
||||
signal_tickers.add(row.stock_code)
|
||||
|
||||
logger.info("🔍 DB 스캐너: %d개 신호 종목 (RSI≤30 + BB하단 + ADX≤30 + MA방향)", len(signal_tickers))
|
||||
return signal_tickers
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("❌ DB 스캐너 실패: %s", exc, exc_info=True)
|
||||
return set()
|
||||
return stats
|
||||
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 7. 메인 배치
|
||||
# 6. 메인 배치
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
async def batch_analyze_all_stocks(limit: int = None):
|
||||
@@ -353,13 +233,14 @@ async def batch_analyze_all_stocks(limit: int = None):
|
||||
1. 시장 레짐 판별 (KOSPI 지수 기준)
|
||||
2. 시가총액 상위 500개 종목 조회
|
||||
3. 세마포어로 BATCH_CONCURRENCY(10)개씩 병렬 분석 (API 호출만)
|
||||
4. 분석 완료 후 BULK_SAVE_CHUNK(20)개씩 일괄 DB 저장
|
||||
4. Streaming: 분석 완료 즉시 BULK_SAVE_CHUNK(20)개 단위 DB UPSERT
|
||||
5. DB 스캐너 실행 (추가 API 호출 없음)
|
||||
6. 워치리스트 초기화 — 스캐너 신호 종목 우선
|
||||
|
||||
성능 개선 (기존 대비):
|
||||
- DB 트랜잭션: 1,000회 → 50회 (20× 감소)
|
||||
- 동시 처리: 5 → 10 종목
|
||||
- DB 트랜잭션: DELETE+INSERT race-condition → 원자적 UPSERT
|
||||
- 메모리: 전체 결과 적재 → Streaming(분석 즉시 저장)
|
||||
- DB 엔진: 스크립트 자체 생성 → 공통 모듈 공유
|
||||
"""
|
||||
start_time = datetime.now(KST)
|
||||
analysis_date = datetime.now(KST).date()
|
||||
@@ -371,45 +252,47 @@ async def batch_analyze_all_stocks(limit: int = None):
|
||||
# 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)
|
||||
# Step 2: 종목 목록 조회 (모듈 레벨 싱글톤 재사용, 불필요한 새 인스턴스 생성 금지)
|
||||
all_stocks = await get_all_tradable_stocks(korea_investment_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: 병렬 분석 — 세마포어로 BATCH_CONCURRENCY 제한
|
||||
# asyncio 단일 스레드이므로 results 리스트 append는 thread-safe
|
||||
# Step 3 + 4: 병렬 분석 & Streaming 저장
|
||||
# 분석 태스크 → queue → _streaming_saver (동시 실행)
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=BULK_SAVE_CHUNK * 3)
|
||||
semaphore = asyncio.Semaphore(BATCH_CONCURRENCY)
|
||||
all_results: list[tuple[str, str, str, Optional[StockRecommendation]]] = []
|
||||
|
||||
async def analyze_one(i: int, code: str, name: str):
|
||||
async with semaphore:
|
||||
status, result = await analyze_one_stock(i, total, code, name)
|
||||
all_results.append((status, code, name, result))
|
||||
try:
|
||||
async with semaphore:
|
||||
status, result = await analyze_one_stock(i, total, code, name)
|
||||
await queue.put((status, code, name, result))
|
||||
except Exception as exc:
|
||||
logger.error("❌ analyze_one unexpected error %s: %s", code, exc)
|
||||
await queue.put(("error", code, name, None))
|
||||
|
||||
# 저장기를 백그라운드로 먼저 시작
|
||||
saver_task = asyncio.create_task(_streaming_saver(queue, analysis_date))
|
||||
|
||||
# 분석 태스크 전체 실행
|
||||
tasks = [analyze_one(i, c, n) for i, (c, n) in enumerate(filtered_stocks, 1)]
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Step 4: 통계 집계
|
||||
ok_items = [(code, name, r) for status, code, name, r in all_results if status == "ok" and r]
|
||||
ok_count = len(ok_items)
|
||||
no_chart = sum(1 for s, _, _, _ in all_results if s == "no_chart")
|
||||
price_flt = sum(1 for s, _, _, _ in all_results if s == "price_filter")
|
||||
err_count = sum(1 for s, _, _, _ in all_results if s in ("error",) or isinstance(s, Exception))
|
||||
# sentinel 전송 → 저장기 종료
|
||||
await queue.put(None)
|
||||
stats = await saver_task
|
||||
|
||||
# Step 5: BULK_SAVE_CHUNK 단위 일괄 DB 저장
|
||||
save_count = 0
|
||||
for chunk_start in range(0, len(ok_items), BULK_SAVE_CHUNK):
|
||||
chunk = ok_items[chunk_start: chunk_start + BULK_SAVE_CHUNK]
|
||||
saved = await bulk_save_results(chunk, analysis_date)
|
||||
save_count += saved
|
||||
logger.info("💾 DB 저장: %d/%d건 완료", save_count, ok_count)
|
||||
ok_count = stats.get("ok", 0)
|
||||
no_chart = stats.get("no_chart", 0)
|
||||
price_flt = stats.get("price_filter", 0)
|
||||
err_count = stats.get("error", 0)
|
||||
save_count = stats.get("saved", 0)
|
||||
|
||||
# Step 6: DB 스캐너 (배치 완료 후 추가 API 호출 없이)
|
||||
# Step 5: DB 스캐너 (배치 완료 후 추가 API 호출 없이)
|
||||
scanner_tickers = await run_db_scanner(analysis_date)
|
||||
|
||||
# Step 7: 워치리스트 초기화 — 스캐너 신호 종목 우선
|
||||
# Step 6: 워치리스트 초기화 — 스캐너 신호 종목 우선
|
||||
try:
|
||||
from app.services.watchlist import initialize_watchlist
|
||||
async with AsyncSessionLocal() as session:
|
||||
@@ -423,7 +306,7 @@ async def batch_analyze_all_stocks(limit: int = None):
|
||||
|
||||
# 결과 요약
|
||||
duration = (datetime.now(KST) - start_time).total_seconds()
|
||||
stats = korea_investment_api.get_monitor_stats()
|
||||
api_stats = korea_investment_api.get_monitor_stats()
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("✅ Batch analysis completed!")
|
||||
@@ -435,8 +318,8 @@ async def batch_analyze_all_stocks(limit: int = None):
|
||||
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'])
|
||||
api_stats['total_calls'], api_stats['total_errors'],
|
||||
api_stats['error_rate'] * 100, api_stats['avg_duration'])
|
||||
logger.info("=" * 60)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user