improve: batch pipeline - market regime detection + DB scanner + watchlist priority
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user