Files
auto-stock/backend/app/services/analysis_db_service.py
2026-02-24 11:38:35 +09:00

197 lines
6.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()