Files
auto-stock/backend/app/services/watchlist.py
2026-02-25 16:56:34 +09:00

143 lines
5.0 KiB
Python

"""
분석종목(Watchlist) 관리 공용 서비스
아침 배치(batch_daily_analysis)와 장중 배치(batch_intraday_analysis)가 공유하는
초기화 함수. 각 배치 스크립트가 자신의 AsyncSession을 전달해 사용한다.
[.env 설정]
WATCHLIST_INIT_SIZE (아침 배치 초기화 종목 수)
WATCHLIST_MAX_SIZE (최대 관리 종목 수)
WATCHLIST_ENTRY_SCORE (장중 신규 진입 기준 점수)
WATCHLIST_EXIT_SCORE (장중 제거 기준 점수)
"""
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.core.config import settings
from app.models.models import WatchlistStock, StockAnalysis
logger = logging.getLogger(__name__)
# 스캐너 신호 종목에 배정하는 최대 슬롯 수 (settings.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(settings.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 = settings.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