"""StockAnalysis DB 서비스 — 단일 진실 공급원 (Single Source of Truth) StockAnalysis ORM 저장 로직을 중앙화하여 3곳의 중복 제거: - batch_daily_analysis.py (bulk) - batch_intraday_analysis.py (per-stock, rescore) PostgreSQL UPSERT(ON CONFLICT DO UPDATE)를 사용하여 DELETE → INSERT 사이의 race condition을 원자적으로 해결. """ from __future__ import annotations import logging from datetime import date, datetime, timezone from typing import TYPE_CHECKING from sqlalchemy import select from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession from app.core.database import AsyncSessionLocal from app.models.models import StockAnalysis logger = logging.getLogger(__name__) if TYPE_CHECKING: from app.api.endpoints.recommendations import StockRecommendation def passes_price_filter(result: "StockRecommendation") -> bool: """5,000원 이하 저가주 필터 (CLAUDE.md 5절). True → 통과 (거래 가능), False → 필터됨 (5,000원 미만). """ return not (result.current_price and result.current_price < 5000) # UPSERT 시 갱신할 컬럼 집합 (PK·unique key·최초등록 시각 제외) _UPSERT_UPDATE_COLS = frozenset({ "stock_name", "current_price", "change_rate", "volume", "rsi", "ma5", "ma20", "ma60", "stochastic_k", "stochastic_d", "bollinger_upper", "bollinger_lower", "bollinger_middle", "signals", "score", "ml_score", "ml_confidence", "ml_signal", "atr_14", "adx_14", "updated_at", }) def to_analysis_dict( stock_code: str, stock_name: str, result: "StockRecommendation", analysis_date: date, ) -> dict: """StockRecommendation → StockAnalysis INSERT 딕셔너리 변환. ORM 객체 대신 dict를 반환하여 단건 UPSERT와 bulk INSERT에서 모두 재사용. """ return { "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, "updated_at": datetime.now(timezone.utc).replace(tzinfo=None), } async def upsert_analysis( session: AsyncSession, stock_code: str, stock_name: str, result: "StockRecommendation", analysis_date: date, ) -> None: """단건 UPSERT — 동일 (stock_code, analysis_date)가 있으면 UPDATE, 없으면 INSERT. DELETE+INSERT 대비: - 원자적 연산 (race condition 없음) - 단일 DB 왕복 (네트워크 절반) """ row = to_analysis_dict(stock_code, stock_name, result, analysis_date) stmt = pg_insert(StockAnalysis).values(**row) stmt = stmt.on_conflict_do_update( constraint="uq_stock_analysis_code_date", set_={col: stmt.excluded[col] for col in _UPSERT_UPDATE_COLS}, ) await session.execute(stmt) async def bulk_upsert_analysis( session: AsyncSession, items: list[tuple[str, str, "StockRecommendation"]], analysis_date: date, ) -> int: """다건 UPSERT — 단일 INSERT ... ON CONFLICT 배치 실행. items: [(stock_code, stock_name, StockRecommendation), ...] Returns: 처리된 건수 """ if not items: return 0 rows = [to_analysis_dict(code, name, r, analysis_date) for code, name, r in items] stmt = pg_insert(StockAnalysis).values(rows) stmt = stmt.on_conflict_do_update( constraint="uq_stock_analysis_code_date", set_={col: stmt.excluded[col] for col in _UPSERT_UPDATE_COLS}, ) await session.execute(stmt) return len(rows) 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: 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() 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 → 강한 하락 추세 → 평균회귀 부적합 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()