Files
auto-stock/backend/app/services/kosdaq_scanner.py
shuim 5f0f839039 전략 취약점 15건 수정 (코드 리뷰 기반)
## CRITICAL/HIGH (S1-S8)
- S1: Aggressive 모드 %B 스케일링 캡 1.3 (BB 중앙선 위 매수 방지)
- S2: ML scaler train-only fit (데이터 누수 제거)
- S3: SIDEWAYS = ADX<20 AND slope≈0 (트렌드 초기 차단 해소)
- S4: EOD 청산 실패 시 재시도 + CRITICAL 로그
- S5: 일일 손실 한도에 미실현 P&L 포함
- S6: trailing stop 고점 매 사이클 갱신
- S7: 섹터 필터 미매핑 종목 "기타" 분류 (max 3 적용)
- S8: 스캐너 RSI 기준 25→46, BB 점수 -3%~+1.5% 구간

## MEDIUM (M1-M7)
- M1: 스캐너 최소 거래량 100만→10만주
- M2: ADX 히스테리시스 18/22 (국면 플리핑 방지)
- M3: 분할매도 최소 수익 1.0%→0.5%
- M4: ORB 갭 필터 4% + 레인지 폭 0.3% 최소
- M5: 쿨다운 스트릭 부분매도 시 미변경
- M6: MDD 강제매도 loss_tracker 기록 제거 (허위 ban 방지)
- M7: RSI Wilder 스무딩으로 통일

## 인프라/보안
- 인증 누락 엔드포인트 수정 (analysis, journals)
- nginx 보안 헤더 location 블록 누락 수정
- Alembic 마이그레이션 IF NOT EXISTS 가드 추가
- SellRequest/UsOrderRequest 입력 검증 강화
- 차트 네이버 폴백 추가 (DB 데이터 없을 때)
- 서버 이전 시 자동 DB 마이그레이션 + KRX 초기 수집
- CLAUDE.md 섹션 5.1 백테스트 값 동기화 (4%→6%, -6%→-7%)
- MAX_SWING_POSITIONS 25→20 (CLAUDE.md 준수)
- mdd_manager failed_count에서 skip 종목 제외

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:44:14 +09:00

210 lines
7.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.
"""
코스닥 전종목 스캐너 (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
# 전일 거래량 최솟값: 10만주 미만 종목 제외 (유동성 부족)
# market_analyzer.py와 동일 수준. LIQUIDITY_PERCENTILE=0.80 이 추가 필터 역할.
MIN_DAILY_VOLUME = 100_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._volume_filter(td.volume):
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 _volume_filter(self, volume: int) -> bool:
"""전일 거래량 100만주 미만 종목 제외 (유동성 부족)"""
return volume >= MIN_DAILY_VOLUME
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
# [S8] RSI 점수: config의 MEAN_REVERSION_RSI_THRESHOLD(46.0) 기준으로 정규화
from app.core.config import settings as _settings
_rsi_threshold = _settings.MEAN_REVERSION_RSI_THRESHOLD # 46.0
rsi_score = max(0.0, (_rsi_threshold - rsi14) / _rsi_threshold)
# [S8] BB 점수: CLAUDE.md 6.2 기준 -3%~+1.5% 구간 보상
# pct_vs_lower <= -3%: 최대 점수 1.0, +1.5% 이상: 점수 0.0
bb_score = min(max(0.0, (1.5 - pct_vs_lower) / 4.5), 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 <= _rsi_threshold:
reason_parts.append(f"RSI({rsi14:.1f})≤{_rsi_threshold:.0f}")
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)]