426 lines
16 KiB
Python
426 lines
16 KiB
Python
"""
|
||
장중 미니배치: 매 10분마다 실행 (평일 09:00~15:30 KST)
|
||
|
||
[흐름]
|
||
1. 아침 배치(batch_daily_analysis.py)가 분석종목(watchlist)을 score 상위 50개로 초기화
|
||
2. 장중 미니배치는 분석종목만 재분석하여 최신 지표로 stock_analysis 갱신
|
||
3. 분석 결과를 watchlist_history에 시계열로 누적 저장
|
||
4. score 기준으로 분석종목 진입(≥60) / 제거(<20) 자동 관리
|
||
|
||
실행: python batch_intraday_analysis.py
|
||
Cron: */10 9-15 * * 1-5 docker exec autostock-backend python /app/batch_intraday_analysis.py >> /logs/batch_intraday.log 2>&1
|
||
"""
|
||
|
||
import asyncio
|
||
import sys
|
||
from datetime import datetime, date
|
||
from pathlib import Path
|
||
|
||
import pytz
|
||
KST = pytz.timezone('Asia/Seoul')
|
||
|
||
sys.path.insert(0, str(Path(__file__).parent))
|
||
|
||
from sqlalchemy import select, update, func
|
||
|
||
from app.core.config import settings
|
||
from app.core.database import AsyncSessionLocal # 🔧 공통 DB 모듈 사용
|
||
from app.core.market_schedule import is_market_open
|
||
from app.models.models import StockAnalysis, WatchlistStock, WatchlistHistory
|
||
from app.api.endpoints.recommendations import analyze_stock_for_batch
|
||
from app.services.analysis_db_service import upsert_analysis, passes_price_filter, run_db_scanner # 🔧 단일화
|
||
from app.services.watchlist import (
|
||
WATCHLIST_MAX_SIZE,
|
||
WATCHLIST_ENTRY_SCORE,
|
||
WATCHLIST_EXIT_SCORE,
|
||
)
|
||
import logging
|
||
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||
)
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 동시 처리 종목 수 (KIS API 세마포어가 추가로 속도 제어)
|
||
BATCH_CONCURRENCY = 5
|
||
|
||
# 워치리스트 외부 후보 재분석 상한 (10분 배치 내에 처리 가능한 수준)
|
||
OUTSIDE_RESCORE_LIMIT = 30
|
||
|
||
|
||
async def get_active_watchlist() -> list[tuple[str, str]]:
|
||
"""현재 활성 분석종목 목록 조회."""
|
||
async with AsyncSessionLocal() as session:
|
||
result = await session.execute(
|
||
select(WatchlistStock.stock_code, WatchlistStock.stock_name)
|
||
.where(WatchlistStock.is_active == True)
|
||
)
|
||
rows = result.all()
|
||
|
||
if not rows:
|
||
logger.warning(
|
||
"⚠️ 분석종목이 없습니다. "
|
||
"아침 배치(batch_daily_analysis.py)가 먼저 실행되었는지 확인하세요."
|
||
)
|
||
else:
|
||
logger.info("📋 분석종목 %d개 대상", len(rows))
|
||
return [(row.stock_code, row.stock_name) for row in rows]
|
||
|
||
|
||
# -------------------------------------------------------------------
|
||
# analyze_update_and_manage 분리: 분석 / 저장 / 관리
|
||
# -------------------------------------------------------------------
|
||
|
||
async def _save_analysis_and_history(
|
||
session,
|
||
stock_code: str,
|
||
stock_name: str,
|
||
result,
|
||
analysis_date: date,
|
||
now: datetime,
|
||
) -> None:
|
||
"""stock_analysis UPSERT + watchlist_history 시계열 기록.
|
||
|
||
DELETE+INSERT race condition 없이 원자적으로 갱신.
|
||
"""
|
||
# 1. stock_analysis UPSERT (race-condition-free)
|
||
await upsert_analysis(session, stock_code, stock_name, result, analysis_date)
|
||
|
||
# 2. watchlist_history 시계열 기록
|
||
session.add(WatchlistHistory(
|
||
stock_code=stock_code,
|
||
stock_name=stock_name,
|
||
recorded_at=now,
|
||
score=result.score,
|
||
current_price=result.current_price,
|
||
change_rate=result.change_rate,
|
||
rsi=result.rsi,
|
||
signals=result.signals,
|
||
))
|
||
|
||
|
||
async def _manage_watchlist_membership(
|
||
session,
|
||
stock_code: str,
|
||
stock_name: str,
|
||
score: int | None,
|
||
now: datetime,
|
||
) -> bool:
|
||
"""score 기준 미달 시 watchlist에서 제거.
|
||
|
||
Returns: True if removed, False if retained.
|
||
"""
|
||
if score is not None and score < WATCHLIST_EXIT_SCORE:
|
||
await session.execute(
|
||
update(WatchlistStock)
|
||
.where(
|
||
WatchlistStock.stock_code == stock_code,
|
||
WatchlistStock.is_active == True,
|
||
)
|
||
.values(
|
||
is_active=False,
|
||
removed_at=now,
|
||
removed_reason="score_drop",
|
||
)
|
||
)
|
||
logger.info(
|
||
"🚫 %s (%s) 분석종목 제거: score=%s < %s",
|
||
stock_code, stock_name, score, WATCHLIST_EXIT_SCORE,
|
||
)
|
||
return True
|
||
return False
|
||
|
||
|
||
async def analyze_update_and_manage(
|
||
stock_code: str,
|
||
stock_name: str,
|
||
analysis_date: date,
|
||
) -> tuple[bool, int | None, bool]:
|
||
"""종목 재분석 → stock_analysis 갱신 → watchlist_history 저장 → watchlist 관리.
|
||
|
||
Returns: (success, score, was_removed)
|
||
was_removed: _manage_watchlist_membership이 실제로 비활성화 처리했으면 True
|
||
"""
|
||
try:
|
||
result = await analyze_stock_for_batch(stock_code, stock_name)
|
||
if not result:
|
||
logger.info("No result for %s (chart/price issue)", stock_code)
|
||
return False, None, False
|
||
|
||
# 5,000원 이하 저가주 → watchlist에서도 제거
|
||
if not passes_price_filter(result):
|
||
logger.debug("Skip %s: price=%.0f원 < 5,000원", stock_code, result.current_price)
|
||
async with AsyncSessionLocal() as session:
|
||
await session.execute(
|
||
update(WatchlistStock)
|
||
.where(
|
||
WatchlistStock.stock_code == stock_code,
|
||
WatchlistStock.is_active == True,
|
||
)
|
||
.values(
|
||
is_active=False,
|
||
removed_at=datetime.now(KST),
|
||
removed_reason="price_filter",
|
||
)
|
||
)
|
||
await session.commit()
|
||
return False, None, True # 가격필터 제거도 was_removed=True
|
||
|
||
now = datetime.now(KST)
|
||
score = result.score
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
await _save_analysis_and_history(
|
||
session, stock_code, stock_name, result, analysis_date, now
|
||
)
|
||
was_removed = await _manage_watchlist_membership(
|
||
session, stock_code, stock_name, score, now
|
||
)
|
||
await session.commit()
|
||
|
||
logger.info("✅ %s (%s): score=%s", stock_code, stock_name, score)
|
||
return True, score, was_removed
|
||
|
||
except Exception as e:
|
||
logger.error("❌ Failed %s: %s", stock_code, e)
|
||
return False, None, False
|
||
|
||
|
||
async def rescore_outside_candidates(
|
||
analysis_date: date,
|
||
current_codes: set[str],
|
||
) -> int:
|
||
"""워치리스트 외부 후보 종목의 score를 실시간 재분석으로 갱신.
|
||
|
||
아침 배치에서 score 상위권이었던 종목이 장중 거래량·뉴스 급등으로
|
||
score가 올라가도 DB에는 오전 값이 그대로 남아 있다.
|
||
이를 포착하기 위해 워치리스트 밖 상위 OUTSIDE_RESCORE_LIMIT개를
|
||
재분석하여 stock_analysis를 최신화한다.
|
||
이후 refresh_watchlist()가 갱신된 score를 보고 신규 추가를 결정한다.
|
||
|
||
Returns: 갱신 성공 종목 수
|
||
"""
|
||
# 아침 배치 기준 score 상위 N개 중 워치리스트 미포함 종목 조회
|
||
async with AsyncSessionLocal() as session:
|
||
exclude = list(current_codes) if current_codes else ["__none__"]
|
||
result = await session.execute(
|
||
select(StockAnalysis.stock_code, StockAnalysis.stock_name)
|
||
.where(
|
||
StockAnalysis.analysis_date == analysis_date,
|
||
StockAnalysis.stock_code.notin_(exclude),
|
||
)
|
||
.order_by(StockAnalysis.score.desc())
|
||
.limit(OUTSIDE_RESCORE_LIMIT)
|
||
)
|
||
candidates = result.all()
|
||
|
||
if not candidates:
|
||
logger.info("외부 후보 종목 없음 — 재분석 건너뜀")
|
||
return 0
|
||
|
||
total = len(candidates)
|
||
logger.info("🔍 워치리스트 외부 후보 %d개 재분석 시작", total)
|
||
|
||
semaphore = asyncio.Semaphore(BATCH_CONCURRENCY)
|
||
|
||
async def rescore_one(i: int, code: str, name: str) -> bool:
|
||
async with semaphore:
|
||
try:
|
||
res = await analyze_stock_for_batch(code, name)
|
||
if not res:
|
||
return False
|
||
# 저가주 필터 (CLAUDE.md 5절 가격 필터)
|
||
if not passes_price_filter(res):
|
||
logger.debug("Skip %s: price=%.0f원 < 5,000원", code, res.current_price)
|
||
return False
|
||
|
||
async with AsyncSessionLocal() as session:
|
||
await upsert_analysis(session, code, name, res, analysis_date)
|
||
await session.commit()
|
||
|
||
logger.debug(" [%d/%d] %s (%s): score=%s", i, total, code, name, res.score)
|
||
return True
|
||
except Exception as e:
|
||
logger.error("❌ 외부 후보 재분석 실패 %s: %s", code, e)
|
||
return False
|
||
|
||
tasks = [
|
||
rescore_one(i, code, name)
|
||
for i, (code, name) in enumerate(candidates, 1)
|
||
]
|
||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||
success = sum(1 for r in results if r is True)
|
||
logger.info("✅ 외부 후보 재분석 완료: %d/%d개 갱신", success, total)
|
||
return success
|
||
|
||
|
||
async def refresh_watchlist(analysis_date: date, current_codes: set[str]) -> int:
|
||
"""분석종목에 없는 고점수 종목을 신규 추가.
|
||
|
||
Args:
|
||
analysis_date: 오늘 날짜 (stock_analysis 기준)
|
||
current_codes: 이번 배치 시작 시점의 watchlist 코드 집합
|
||
|
||
Returns: 추가된 종목 수
|
||
"""
|
||
async with AsyncSessionLocal() as session:
|
||
# 현재 활성 종목 수 확인
|
||
count_result = await session.execute(
|
||
select(func.count())
|
||
.select_from(WatchlistStock)
|
||
.where(WatchlistStock.is_active == True)
|
||
)
|
||
active_count = count_result.scalar() or 0
|
||
|
||
slots = WATCHLIST_MAX_SIZE - active_count
|
||
if slots <= 0:
|
||
logger.debug("Watchlist 최대치 도달 (%d/%d)", active_count, WATCHLIST_MAX_SIZE)
|
||
return 0
|
||
|
||
# stock_analysis에서 고점수 종목 중 현재 watchlist에 없는 것 조회
|
||
exclude_codes = list(current_codes) if current_codes else ["__none__"]
|
||
result = await session.execute(
|
||
select(
|
||
StockAnalysis.stock_code,
|
||
StockAnalysis.stock_name,
|
||
StockAnalysis.score,
|
||
)
|
||
.where(
|
||
StockAnalysis.analysis_date == analysis_date,
|
||
StockAnalysis.score >= WATCHLIST_ENTRY_SCORE,
|
||
StockAnalysis.stock_code.notin_(exclude_codes),
|
||
)
|
||
.order_by(StockAnalysis.score.desc())
|
||
.limit(slots)
|
||
)
|
||
candidates = result.all()
|
||
|
||
if not candidates:
|
||
return 0
|
||
|
||
now = datetime.now(KST)
|
||
for row in candidates:
|
||
session.add(WatchlistStock(
|
||
stock_code=row.stock_code,
|
||
stock_name=row.stock_name,
|
||
added_at=now,
|
||
added_reason="score_rise",
|
||
score_at_add=row.score,
|
||
is_active=True,
|
||
))
|
||
|
||
await session.commit()
|
||
logger.info(
|
||
"➕ 분석종목 %d개 신규 추가 (score ≥ %d, 현재 %d개)",
|
||
len(candidates), WATCHLIST_ENTRY_SCORE, active_count + len(candidates),
|
||
)
|
||
return len(candidates)
|
||
|
||
|
||
async def main():
|
||
"""장중 미니배치 메인"""
|
||
# 장중(09:00~15:30 KST 평일)이 아니면 즉시 종료
|
||
if not is_market_open():
|
||
logger.info("📴 장중이 아닙니다 (09:00~15:30 평일). 종료합니다.")
|
||
return
|
||
|
||
start_time = datetime.now(KST)
|
||
analysis_date = datetime.now(KST).date()
|
||
logger.info(
|
||
"⚡ [Intraday] %s 시작 — 분석종목 재분석 (진입≥%d, 제거<%d)",
|
||
start_time.strftime("%H:%M:%S"), WATCHLIST_ENTRY_SCORE, WATCHLIST_EXIT_SCORE,
|
||
)
|
||
|
||
# 활성 분석종목 조회
|
||
active_stocks = await get_active_watchlist()
|
||
if not active_stocks:
|
||
return
|
||
|
||
# 이번 배치 시작 시점의 코드 집합 (신규 추가 시 중복 방지용)
|
||
current_codes = {code for code, _ in active_stocks}
|
||
total = len(active_stocks)
|
||
|
||
# 병렬 재분석
|
||
semaphore = asyncio.Semaphore(BATCH_CONCURRENCY)
|
||
|
||
async def analyze_one(i: int, code: str, name: str) -> tuple[bool, int | None, bool]:
|
||
async with semaphore:
|
||
logger.info("[%d/%d] %s (%s)", i, total, code, name)
|
||
return await analyze_update_and_manage(code, name, analysis_date)
|
||
|
||
tasks = [
|
||
analyze_one(i, code, name)
|
||
for i, (code, name) in enumerate(active_stocks, 1)
|
||
]
|
||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||
|
||
analyzed_ok = sum(1 for r in results if isinstance(r, tuple) and r[0] is True)
|
||
removed = sum(1 for r in results if isinstance(r, tuple) and len(r) == 3 and r[2] is True)
|
||
retained = analyzed_ok - sum(
|
||
1 for r in results if isinstance(r, tuple) and len(r) == 3 and r[0] is True and r[2] is True
|
||
)
|
||
fail = sum(1 for r in results if isinstance(r, tuple) and r[0] is False)
|
||
error = sum(1 for r in results if isinstance(r, Exception))
|
||
|
||
# 워치리스트 외부 후보 재분석 — score DB 최신화 (refresh_watchlist 전에 실행)
|
||
await rescore_outside_candidates(analysis_date, current_codes)
|
||
|
||
# 장중 DB 스캐너 실행 — 장중 과매도 신규 종목 포착 (RSI≤30 + BB하단 + ADX≤30 + MA방향)
|
||
scanner_tickers = await run_db_scanner(analysis_date)
|
||
scanner_new = scanner_tickers - current_codes # 워치리스트 미포함 신규 신호 종목
|
||
added_scanner = 0
|
||
if scanner_new:
|
||
now_kst = datetime.now(KST)
|
||
async with AsyncSessionLocal() as session:
|
||
# 현재 활성 종목 수 확인
|
||
count_result = await session.execute(
|
||
select(func.count())
|
||
.select_from(WatchlistStock)
|
||
.where(WatchlistStock.is_active == True)
|
||
)
|
||
active_count = count_result.scalar() or 0
|
||
slots = WATCHLIST_MAX_SIZE - active_count
|
||
|
||
for code in list(scanner_new)[:slots]:
|
||
# stock_analysis에서 종목명 조회
|
||
name_result = await session.execute(
|
||
select(StockAnalysis.stock_name)
|
||
.where(
|
||
StockAnalysis.stock_code == code,
|
||
StockAnalysis.analysis_date == analysis_date,
|
||
)
|
||
.limit(1)
|
||
)
|
||
name_row = name_result.first()
|
||
if not name_row:
|
||
continue
|
||
session.add(WatchlistStock(
|
||
stock_code=code,
|
||
stock_name=name_row.stock_name,
|
||
added_at=now_kst,
|
||
added_reason="intraday_scanner",
|
||
score_at_add=None,
|
||
is_active=True,
|
||
))
|
||
added_scanner += 1
|
||
await session.commit()
|
||
|
||
if added_scanner:
|
||
logger.info("📡 장중 스캐너 신규 추가: %d개 (RSI≤30+BB하단)", added_scanner)
|
||
current_codes |= scanner_new # refresh_watchlist 중복 방지용 갱신
|
||
|
||
# 고점수 신규 종목 추가 (스캐너 추가분 제외)
|
||
added = await refresh_watchlist(analysis_date, current_codes)
|
||
|
||
duration = (datetime.now(KST) - start_time).total_seconds()
|
||
logger.info(
|
||
"✅ [Intraday] 완료: %d/%d 유지, %d 제거, +%d 신규(score), +%d 신규(스캐너), "
|
||
"%d 실패, %d 오류 — %.1f초",
|
||
retained, total, removed, added, added_scanner, fail, error, duration,
|
||
)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
asyncio.run(main())
|