""" 분석종목(Watchlist) 관리 공용 서비스 아침 배치(batch_daily_analysis)와 장중 배치(batch_intraday_analysis)가 공유하는 초기화 함수. 각 배치 스크립트가 자신의 AsyncSession을 전달해 사용한다. [승률 개선] - scanner_tickers가 제공되면 해당 종목을 워치리스트 앞쪽에 우선 배치 - 나머지 슬롯은 score 상위 순으로 채움 - added_reason: "scanner_signal" | "morning_batch" 로 구분되어 UI 에서도 식별 가능 """ from datetime import datetime, date from typing import Optional import logging import pytz from sqlalchemy import select, update from sqlalchemy.ext.asyncio import AsyncSession from app.models.models import WatchlistStock, StockAnalysis logger = logging.getLogger(__name__) # 분석종목 관련 상수 WATCHLIST_INIT_SIZE: int = 50 # 아침 배치 초기화 종목 수 WATCHLIST_MAX_SIZE: int = 100 # 최대 관리 종목 수 WATCHLIST_ENTRY_SCORE: int = 60 # 장중 신규 진입 기준 점수 WATCHLIST_EXIT_SCORE: int = 20 # 장중 제거 기준 점수 # 스캐너 신호 종목에 배정하는 최대 슬롯 수 (WATCHLIST_INIT_SIZE 중 이 수만큼 우선) SCANNER_PRIORITY_SLOTS: int = 20 async def initialize_watchlist( session: AsyncSession, analysis_date: date, scanner_tickers: Optional[set[str]] = None, ) -> int: """아침 배치 완료 후 분석종목 초기화. 워치리스트 구성 우선순위: 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: 추가된 종목 수 """ scanner_tickers = scanner_tickers or set() now = datetime.now(pytz.timezone("Asia/Seoul")).replace(tzinfo=None) # 1. 기존 활성 종목 전부 비활성화 await session.execute( update(WatchlistStock) .where(WatchlistStock.is_active == True) .values(is_active=False, removed_at=now, removed_reason="daily_reset") ) # 2. 오늘 score 상위 N개 조회 result = await session.execute( select(StockAnalysis.stock_code, StockAnalysis.stock_name, StockAnalysis.score) .where(StockAnalysis.analysis_date == analysis_date) .order_by(StockAnalysis.score.desc()) .limit(WATCHLIST_INIT_SIZE) ) top_stocks = result.all() top_codes = {row.stock_code for row in top_stocks} added = 0 scanner_added = 0 added_codes: set[str] = set() scanner_slots = SCANNER_PRIORITY_SLOTS # --- 1순위: score 상위 + 스캐너 동시 포함 종목 (스캐너 신호로 마크) --- for row in top_stocks: if row.stock_code not in scanner_tickers: continue if scanner_slots <= 0: break 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) scanner_slots -= 1 added += 1 scanner_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 scanner_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() logger.info( "📋 워치리스트 초기화: %d개 (scanner_signal=%d, morning_batch=%d, 기준일: %s)", added, scanner_added, added - scanner_added, analysis_date, ) return added