Files
auto-stock/backend/app/services/kosdaq_scanner.py
2026-02-22 23:51:10 +09:00

195 lines
6.9 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.
"""
코스닥 전종목 스캐너 (CLAUDE.md 섹션 8.1 — 승률 최적화)
매일 장 마감 후 코스닥 전종목(~1,600종목)을 스캔하여
RSI + 볼린저 밴드 + 거래량 복합 점수로 상위 20종목을 선별한다.
[승률 개선 항목]
1. RSI 방향 필터: RSI가 바닥에서 상승 전환 중인 종목만 선별
2. 최소 점수 임계값 0.15 적용 (저품질 신호 제외)
3. 승률 필드(rsi_turning) ScanResult에 노출
"""
import logging
from dataclasses import dataclass, field
from typing import List, Optional
logger = logging.getLogger(__name__)
# 가격 필터 (CLAUDE.md 섹션 6.4)
MIN_PRICE_KRW = 5_000
# 거래대금 상위 20% 임계값 (하위 80% 제외)
LIQUIDITY_PERCENTILE = 0.80
# 스캔 결과 상위 N개
TOP_N = 20
# 승률 개선: 최소 점수 임계값 (저품질 신호 필터)
MIN_SCORE = 0.15
# RSI 방향 확인용 과거 봉 수
RSI_DIRECTION_LOOKBACK = 3
@dataclass
class ScanResult:
ticker: str
score: float
rsi14: float
pct_vs_bb_lower: float # (close - bb_lower) / bb_lower * 100
volume_ratio: float # current_volume / volume_ma20
close: float
rsi_turning: bool = False # RSI 반등 신호 여부 (승률 지표)
reason: str = ""
@dataclass
class TickerData:
"""스캐너에 필요한 종목 데이터 구조체"""
ticker: str
close: float
closes: List[float] # 최근 60일 이상 종가
volume: int
volumes: List[int] # 최근 20일 거래량
turnover: float # 당일 거래대금 (원)
turnovers: List[float] # 최근 20일 거래대금
class KosdaqScanner:
"""코스닥 전종목 스캐너 (승률 최적화)
매일 장 마감 후 scan_all()을 호출하면 조건을 충족하는 상위 20종목을
점수 내림차순으로 반환한다.
스코어링 공식:
score = rsi_score × 0.5 + bb_score × 0.3 + volume_score × 0.2
→ score > 0.15 이상인 종목만 선별 (MIN_SCORE 필터)
승률 햵심 필터 (rsi_turning=True):
RSI가 바닥에서 반등 중인 종목은 ScanResult.rsi_turning=True로 표시되며,
포트폴리오 매니저 쪽에서 제일 먼저 진입 처리하는 것을 권장한다.
"""
def __init__(self, min_price: int = MIN_PRICE_KRW, top_n: int = TOP_N):
self.min_price = min_price
self.top_n = top_n
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def scan_all(self, tickers_data: List[TickerData]) -> List[ScanResult]:
"""코스닥 전종목 스캔 → 상위 N개 ScanResult 반환
rsi_turning=True 종목은 리스트 앞쪽으로 정렬하여
포트폴리오 매니저가 우선 진입할 수 있도록 한다.
"""
liquidity_threshold = self._calc_liquidity_threshold(tickers_data)
candidates: List[ScanResult] = []
for td in tickers_data:
if not self._price_filter(td.close):
continue
if not self._liquidity_filter(td.turnover, liquidity_threshold):
continue
result = self._score(td)
if result is not None:
candidates.append(result)
# rsi_turning=True 종목 우선, 동점이면 점수 내림차순
candidates.sort(key=lambda x: (not x.rsi_turning, -x.score))
return candidates[: self.top_n]
# ------------------------------------------------------------------
# Filters
# ------------------------------------------------------------------
def _price_filter(self, close: float) -> bool:
"""CLAUDE.md 섹션 6.4: 국내 5,000원 이하 매수 금지"""
return close >= self.min_price
def _liquidity_filter(self, turnover: float, threshold: float) -> bool:
"""거래대금 상위 20% 이내 필터"""
return turnover >= threshold
# ------------------------------------------------------------------
# Scoring
# ------------------------------------------------------------------
def _score(self, td: TickerData) -> Optional[ScanResult]:
"""RSI + 볼린저 밴드 + 거래량 복합 점수 계산
승률 개선:
- MIN_SCORE(0.15) 이하 종목 제외
- RSI 반등 여부(rsi_turning) 노출
"""
from .mean_reversion_strategy import calculate_rsi, calculate_bollinger
closes = td.closes
min_len = 25 + RSI_DIRECTION_LOOKBACK
if len(closes) < min_len:
return None
rsi14 = calculate_rsi(closes, 14)
bb = calculate_bollinger(closes, 20)
if rsi14 is None or bb is None:
return None
_, _, bb_lower = bb
if bb_lower == 0:
return None
volume_ma20 = sum(td.volumes[-20:]) / 20 if len(td.volumes) >= 20 else None
if volume_ma20 is None or volume_ma20 == 0:
return None
pct_vs_lower = (td.close - bb_lower) / bb_lower * 100
volume_ratio = td.volume / volume_ma20
rsi_score = max(0.0, (25.0 - rsi14) / 25.0)
bb_score = min(max(0.0, (0.0 - pct_vs_lower) / 3.0), 1.0) # 상한 1.0 — 폭락 종목 점수 왜곡 방지
volume_score = min(volume_ratio, 2.0)
total_score = rsi_score * 0.5 + bb_score * 0.3 + volume_score * 0.2
# 승률 필터: 최소 점수 미달 제외
if total_score <= MIN_SCORE:
return None
# RSI 반등 방향 확인 (승률 지표)
rsi_prev = calculate_rsi(closes[:-RSI_DIRECTION_LOOKBACK], 14)
rsi_turning = rsi_prev is not None and rsi14 > rsi_prev
reason_parts = []
if rsi14 <= 25:
reason_parts.append(f"RSI({rsi14:.1f})≤25")
if pct_vs_lower <= 0:
reason_parts.append(f"BB하단({pct_vs_lower:.1f}%)")
if volume_ratio >= 1.5:
reason_parts.append(f"거래량({volume_ratio:.1f}x)")
if rsi_turning:
reason_parts.append("RSI반등↑")
return ScanResult(
ticker=td.ticker,
score=round(total_score, 4),
rsi14=round(rsi14, 2),
pct_vs_bb_lower=round(pct_vs_lower, 2),
volume_ratio=round(volume_ratio, 2),
close=td.close,
rsi_turning=rsi_turning,
reason=" + ".join(reason_parts) if reason_parts else "복합점수",
)
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
@staticmethod
def _calc_liquidity_threshold(tickers_data: List[TickerData]) -> float:
"""거래대금 상위 20% 임계값 (80번째 백분위수)"""
turnovers = sorted([td.turnover for td in tickers_data if td.turnover > 0])
if not turnovers:
return 0.0
idx = int(len(turnovers) * LIQUIDITY_PERCENTILE)
return turnovers[min(idx, len(turnovers) - 1)]