import logging from typing import Optional from app.core.config import settings from app.services.mean_reversion_strategy import calculate_adx logger = logging.getLogger(__name__) # Trend filter defaults (configurable via settings) TREND_FILTER_ENABLED = getattr(settings, "TREND_FILTER_ENABLED", True) TREND_MA_SHORT = int(getattr(settings, "TREND_MA_SHORT", 20)) TREND_MA_LONG = int(getattr(settings, "TREND_MA_LONG", 60)) TREND_TOLERANCE_PCT = float(getattr(settings, "TREND_TOLERANCE_PCT", 0.005)) # 급등 과열 필터 defaults OVERHEAT_FILTER_ENABLED = getattr(settings, "OVERHEAT_FILTER_ENABLED", True) OVERHEAT_3DAY_MAX = float(getattr(settings, "OVERHEAT_3DAY_MAX", 15.0)) OVERHEAT_5DAY_MAX = float(getattr(settings, "OVERHEAT_5DAY_MAX", 20.0)) OVERHEAT_10DAY_MAX = float(getattr(settings, "OVERHEAT_10DAY_MAX", 30.0)) OVERHEAT_DISPARITY_MAX = float(getattr(settings, "OVERHEAT_DISPARITY_MAX", 108.0)) def _ma(values: list, window: int) -> Optional[float]: try: if len(values) < window: return None return sum(values[-window:]) / window except Exception: return None def _ema(values: list, window: int) -> Optional[float]: """EMA(Exponential Moving Average) 계산. SMA 시드 → 이후 지수 가중 평균 순차 적용. values 길이가 window 미만이면 None 반환. """ try: if len(values) < window: return None k = 2.0 / (window + 1) ema = sum(values[:window]) / window for v in values[window:]: ema = v * k + ema * (1 - k) return ema except Exception: return None def _passes_uptrend(closes: list, ma_short: int = TREND_MA_SHORT, ma_long: int = TREND_MA_LONG, tol: float = TREND_TOLERANCE_PCT) -> Optional[bool]: if not closes or len(closes) < max(ma_short, ma_long): return None ma_s = _ma(closes, ma_short) ma_l = _ma(closes, ma_long) close = closes[-1] if ma_s is None or ma_l is None: return None return (ma_s >= ma_l * (1 - tol)) and (close >= ma_s * (1 - tol)) def analyze_overheat(closes: list, ma20: Optional[float]) -> dict: """급등 과열 필터 - 단기 급등 종목 매수 차단""" details = {} reasons = [] if len(closes) >= 4: gain_3d = ((closes[-1] - closes[-4]) / closes[-4]) * 100 if closes[-4] > 0 else 0 details["gain_3d"] = round(gain_3d, 1) if gain_3d > OVERHEAT_3DAY_MAX: reasons.append(f"3일 +{gain_3d:.1f}% (>{OVERHEAT_3DAY_MAX}%)") if len(closes) >= 6: gain_5d = ((closes[-1] - closes[-6]) / closes[-6]) * 100 if closes[-6] > 0 else 0 details["gain_5d"] = round(gain_5d, 1) if gain_5d > OVERHEAT_5DAY_MAX: reasons.append(f"5일 +{gain_5d:.1f}% (>{OVERHEAT_5DAY_MAX}%)") if len(closes) >= 11: gain_10d = ((closes[-1] - closes[-11]) / closes[-11]) * 100 if closes[-11] > 0 else 0 details["gain_10d"] = round(gain_10d, 1) if gain_10d > OVERHEAT_10DAY_MAX: reasons.append(f"10일 +{gain_10d:.1f}% (>{OVERHEAT_10DAY_MAX}%)") if ma20 and ma20 > 0: disparity = (closes[-1] / ma20) * 100 details["disparity_20"] = round(disparity, 1) if disparity > OVERHEAT_DISPARITY_MAX: reasons.append(f"이격도 {disparity:.1f}% (>{OVERHEAT_DISPARITY_MAX}%)") blocked = len(reasons) > 0 reason = "급등과열: " + ", ".join(reasons) if blocked else "" return {"blocked": blocked, "reason": reason, "details": details} def analyze_custom_signals(chart_data): """커스텀 매매 공식 기반 신호 분석 - 이격도 필터 적용""" closes = [float(item.get("stck_clpr", 0)) for item in chart_data] if len(closes) < 120: return None ma20 = sum(closes[-20:]) / 20 ma60 = sum(closes[-60:]) / 60 ma120 = sum(closes[-120:]) / 120 close = closes[-1] disparity = (close / ma20) * 100 if ma20 else 100 if len(closes) >= 4: price_3days_ago = closes[-4] recent_gain = ((close - price_3days_ago) / price_3days_ago) * 100 if price_3days_ago > 0 else 0 if recent_gain > 10: return {"signal": None, "description": f"급등 종목 추격 금지 (3일 +{recent_gain:.1f}%)"} if ma20 > ma60 > ma120 and close > ma20: if disparity <= 102: return {"signal": "buy", "description": "정배열 눌림목 매수 (이격도 안정)"} else: return {"signal": None, "description": f"정배열이나 이격 과열 ({disparity:.1f}%)"} elif ma20 < ma60 < ma120 and close < ma20: return {"signal": "sell", "description": "역배열 하락 매도타점"} recent_lows = [float(item.get("stck_lwpr", 0)) for item in chart_data[-5:]] if any(low < ma20 for low in recent_lows) and close > ma20: if disparity <= 101: return {"signal": "buy", "description": "눌림목 진입 매수타점"} else: return {"signal": None, "description": f"눌림목이나 이격 과열 ({disparity:.1f}%)"} stop_loss_line = ma60 take_profit_line = ma20 * 1.05 if close < stop_loss_line: return {"signal": "exit", "description": "손절라인 이탈"} if close > take_profit_line: return {"signal": "take_profit", "description": "익절라인 돌파"} return None def analyze_macd(chart_data: list) -> dict: if len(chart_data) < 26: return {"signal": None, "description": "데이터 부족"} prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-26:]] if len(prices) < 26: return {"signal": None, "description": "데이터 부족"} def calculate_ema(prices, period): ema = [sum(prices[:period]) / period] multiplier = 2 / (period + 1) for price in prices[period:]: ema.append((price - ema[-1]) * multiplier + ema[-1]) return ema ema12 = calculate_ema(prices, 12) ema26 = calculate_ema(prices, 26) if len(ema12) < 2 or len(ema26) < 2: return {"signal": None, "description": "데이터 부족"} macd = [ema12[i] - ema26[i] for i in range(len(ema26))] signal = calculate_ema(macd, 9) if len(signal) < 2: return {"signal": None, "description": "데이터 부족"} if macd[-2] <= signal[-2] and macd[-1] > signal[-1]: return {"signal": "buy", "description": "MACD 골든크로스"} elif macd[-2] >= signal[-2] and macd[-1] < signal[-1]: return {"signal": "sell", "description": "MACD 데드크로스"} return {"signal": None, "description": "신호 없음"} def analyze_rsi(chart_data: list) -> dict: """RSI 분석 + 볼린저 하단 결합 진입 신호 [2026-03 수정] RSI < 30 구간에서도 반등 확인 시 BUY 신호 발생 - 기존: rsi < 30 → signal=None (항상 반등 대기만) - 개선: rsi < 30 AND rsi > rsi_prev(반등 시작) AND BB 하단 근처 → BUY → 이유: RSI 30 이하에서 반등이 시작된 시점이 핵심 진입 기회이며, 30~45 구간으로 올라오기까지 기다리면 이미 3~5% 상승 후 진입 """ rsi_period = settings.RSI_PERIOD # 기본값 14 if len(chart_data) < rsi_period + 1: return {"signal": None, "description": "데이터 부족"} prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-(rsi_period+1):]] gains = [] losses = [] for i in range(1, len(prices)): change = prices[i] - prices[i-1] if change > 0: gains.append(change) losses.append(0) else: gains.append(0) losses.append(abs(change)) avg_gain = sum(gains[-rsi_period:]) / rsi_period avg_loss = sum(losses[-rsi_period:]) / rsi_period if avg_loss == 0: rsi = 100 else: rs = avg_gain / avg_loss rsi = 100 - (100 / (1 + rs)) # 직전 RSI 계산 (반등 확인용) if len(chart_data) >= rsi_period + 2: prev_prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-(rsi_period+2):-(1)]] prev_gains = [] prev_losses = [] for i in range(1, len(prev_prices)): change = prev_prices[i] - prev_prices[i-1] if change > 0: prev_gains.append(change) prev_losses.append(0) else: prev_gains.append(0) prev_losses.append(abs(change)) prev_avg_gain = sum(prev_gains[-rsi_period:]) / rsi_period prev_avg_loss = sum(prev_losses[-rsi_period:]) / rsi_period if prev_avg_loss == 0: rsi_prev = 100 else: prev_rs = prev_avg_gain / prev_avg_loss rsi_prev = 100 - (100 / (1 + prev_rs)) else: rsi_prev = rsi # [2026-03] RSI < 30 구간: 반등 시작 + BB 하단 근처 시 BUY if rsi < 30: rsi_rebounding = rsi > rsi_prev # RSI 바닥 찍고 반등 중 if rsi_rebounding: # 볼린저 하단 근처 확인 (-3% ~ +1.5%) bb_lower = None if len(chart_data) >= 20: bb_closes = [float(item.get("stck_clpr", 0)) for item in chart_data[-20:]] bb_mid = sum(bb_closes) / 20 bb_std = (sum((p - bb_mid) ** 2 for p in bb_closes) / 20) ** 0.5 bb_lower = bb_mid - 2 * bb_std close = float(chart_data[-1].get("stck_clpr", 0)) if bb_lower and bb_lower * 0.97 <= close <= bb_lower * 1.015: return { "signal": "buy", "description": f"RSI 바닥 탈출 반등 ({rsi_prev:.1f}→{rsi:.1f}) + BB하단" } else: # BB 하단 근처는 아니지만 RSI 반등 중: 약한 신호 return { "signal": None, "description": f"RSI 과매도 반등 중 ({rsi_prev:.1f}→{rsi:.1f}) - BB하단 대기" } else: # RSI 여전히 하락 중 → 낙하하는 칼 잡기 방지 return {"signal": None, "description": f"RSI 과매도 심화 ({rsi:.1f}) - 반등 대기"} # 기존 30~45 반등 구간 로직 유지 elif 30 <= rsi <= 45 and rsi > rsi_prev: return {"signal": "buy", "description": f"RSI 바닥 탈출 반등 ({rsi:.1f})"} elif rsi > 70: return {"signal": "sell", "description": f"RSI 과매수 ({rsi:.1f})"} elif 45 <= rsi <= 60: return {"signal": "buy", "description": f"RSI 안정 상승 ({rsi:.1f})"} return {"signal": None, "description": f"RSI 중립 ({rsi:.1f})"} def analyze_bollinger(chart_data: list) -> dict: bb_period = settings.BOLLINGER_PERIOD # 기본값 20 bb_stddev = settings.BOLLINGER_STDDEV # 기본값 2.0 if len(chart_data) < bb_period: return {"signal": None, "description": "데이터 부족"} prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-bb_period:]] ma_bb = sum(prices) / len(prices) variance = sum((p - ma_bb) ** 2 for p in prices) / len(prices) std_dev = variance ** 0.5 upper_band = ma_bb + (bb_stddev * std_dev) lower_band = ma_bb - (bb_stddev * std_dev) current_price = prices[-1] if current_price < lower_band: return {"signal": "buy", "description": "볼린저 하한선 터치"} elif current_price > upper_band: return {"signal": "sell", "description": "볼린저 상한선 돌파"} return {"signal": None, "description": "볼린저 중립"} def analyze_moving_average(chart_data: list) -> dict: ma_long = settings.MA_LONG_PERIOD # 기본값 60 ma_short = settings.MA_MEDIUM_PERIOD # 기본값 20 ma_quick = settings.MA_SHORT_PERIOD # 기본값 5 if len(chart_data) < ma_long: return {"signal": None, "description": "데이터 부족"} prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-ma_long:]] ma5 = sum(prices[-ma_quick:]) / ma_quick ma20 = sum(prices[-ma_short:]) / ma_short ma60 = sum(prices[-ma_long:]) / ma_long current_price = prices[-1] if ma5 > ma20 and current_price > ma5: return {"signal": "buy", "description": "5일선 > 20일선"} elif ma20 > ma60 and current_price > ma20: return {"signal": "buy", "description": "20일선 > 60일선"} elif ma5 < ma20 < ma60 and current_price < ma5: return {"signal": "sell", "description": "이동평균 역배열"} return {"signal": None, "description": "이동평균 중립"} def analyze_stochastic(chart_data: list) -> dict: if len(chart_data) < 14: return {"signal": None, "description": "데이터 부족"} recent_data = chart_data[-14:] highs = [float(item.get("stck_hgpr", 0)) for item in recent_data] lows = [float(item.get("stck_lwpr", 0)) for item in recent_data] closes = [float(item.get("stck_clpr", 0)) for item in recent_data] highest_high = max(highs) lowest_low = min(lows) current_close = closes[-1] if highest_high == lowest_low: return {"signal": None, "description": "데이터 부족"} k = ((current_close - lowest_low) / (highest_high - lowest_low)) * 100 if k < 30: return {"signal": "buy", "description": f"스토캐스틱 과매도 (%K: {k:.1f})"} elif k > 70: return {"signal": "sell", "description": f"스토캐스틱 과매수 (%K: {k:.1f})"} return {"signal": None, "description": f"스토캐스틱 중립 (%K: {k:.1f})"} def analyze_volatility_breakout(chart_data: list, k: float = 0.5) -> dict: if len(chart_data) < 2: return {"signal": None, "description": "데이터 부족"} today = chart_data[-1] yesterday = chart_data[-2] current_price = float(today.get("stck_clpr", 0)) open_price = float(today.get("stck_oprc", 0)) prev_high = float(yesterday.get("stck_hgpr", 0)) prev_low = float(yesterday.get("stck_lwpr", 0)) prev_range = prev_high - prev_low if prev_range == 0 or open_price == 0: return {"signal": None, "description": "데이터 부족"} target_price = open_price + (prev_range * k) if current_price >= target_price: return {"signal": "buy", "description": f"변동성 돌파 발생 (목표가: {target_price:.0f})"} return {"signal": None, "description": "변동성 돌파 미달"} def analyze_adx(chart_data: list) -> dict: if len(chart_data) < 29: return {"signal": None, "description": "데이터 부족", "adx": None} highs = [float(item.get("stck_hgpr", 0)) for item in chart_data] lows = [float(item.get("stck_lwpr", 0)) for item in chart_data] closes = [float(item.get("stck_clpr", 0)) for item in chart_data] adx = calculate_adx(highs, lows, closes) if adx is None: return {"signal": None, "description": "ADX 계산 불가", "adx": None} if adx < 20: return {"signal": "buy", "description": f"ADX({adx:.0f}<20) 횡보장 — 평균회귀 유리", "adx": adx} if adx > 30: return {"signal": "sell", "description": f"ADX({adx:.0f}>30) 강추세 — 평균회귀 위험", "adx": adx} return {"signal": None, "description": f"ADX({adx:.0f}) 중립", "adx": adx} def analyze_obv(chart_data: list) -> dict: if len(chart_data) < 2: return {"signal": None, "description": "데이터 부족"} volumes = [int(item.get("acml_vol", 0)) for item in chart_data[-5:]] closes = [float(item.get("stck_clpr", 0)) for item in chart_data[-5:]] if len(volumes) < 2 or len(closes) < 2: return {"signal": None, "description": "데이터 부족"} if volumes[-1] > volumes[-2] * 1.5 and closes[-1] > closes[-2]: return {"signal": "buy", "description": "거래량 증가와 상승"} elif volumes[-1] < volumes[-2] * 0.5 and closes[-1] < closes[-2]: return {"signal": "sell", "description": "거래량 감소와 하락"} return {"signal": None, "description": "거래량 중립"}