"""RSI·MACD 다이버전스 매수·매도 타점 (Ground Truth, 사후 검증).""" from __future__ import annotations from dataclasses import dataclass import pandas as pd from deepcoin.techniques.indicators import macd, rsi @dataclass(frozen=True) class DivergenceSignal: """다이버전스 신호.""" side: str # buy | sell bar_index: int price: float datetime: pd.Timestamp indicator: str # rsi | macd_hist price_prev: float price_curr: float ind_prev: float ind_curr: float def find_divergence_signals( df: pd.DataFrame, local_order: int = 15, min_bars_between: int = 100, max_pair_lookback_bars: int = 5000, rsi_period: int = 14, min_rsi_diff: float = 2.0, min_macd_hist_diff: float = 0.0, min_price_move_pct: float = 1.5, future_bars: int = 2000, min_future_move_pct: float = 2.0, ) -> tuple[list[DivergenceSignal], list[DivergenceSignal]]: """가격·지표 다이버전스 매수·매도 후보를 찾는다. - 상승 다이버전스(매수): 가격 LL + RSI/MACD HL - 하락 다이버전스(매도): 가격 HH + RSI/MACD LH - 미래 데이터로 이후 유의미한 반등·하락을 사후 검증 Args: df: OHLCV DataFrame. local_order: 국소 극값 반경(봉). min_bars_between: 연속 다이버전스 최소 간격(봉). max_pair_lookback_bars: 비교할 이전 극값 최대 거리(봉). rsi_period: RSI 기간. min_rsi_diff: RSI 다이버전스 최소 차이(포인트). min_macd_hist_diff: MACD 히스토그램 최소 차이. min_price_move_pct: 극값 간 최소 가격 변동(%). future_bars: 사후 검증 구간(봉). min_future_move_pct: 사후 최소 가격 변동(%). Returns: (매수 신호, 매도 신호) 리스트. """ if len(df) < local_order * 4 + rsi_period: return [], [] close = df["close"].astype(float) low = df["low"].astype(float) high = df["high"].astype(float) rsi_vals = rsi(close, period=rsi_period) _, _, macd_hist = macd(close) lows = _find_local_extrema(df, low, rsi_vals, macd_hist, local_order, "low") highs = _find_local_extrema(df, high, rsi_vals, macd_hist, local_order, "high") bull_rsi = _detect_bullish( df, lows, rsi_vals, "rsi", min_rsi_diff, min_price_move_pct, max_pair_lookback_bars, future_bars, min_future_move_pct, high, ) bear_rsi = _detect_bearish( df, highs, rsi_vals, "rsi", min_rsi_diff, min_price_move_pct, max_pair_lookback_bars, future_bars, min_future_move_pct, low, ) bull_macd: list[DivergenceSignal] = [] bear_macd: list[DivergenceSignal] = [] if min_macd_hist_diff > 0: bull_macd = _detect_bullish( df, lows, macd_hist, "macd_hist", min_macd_hist_diff, min_price_move_pct, max_pair_lookback_bars, future_bars, min_future_move_pct, high, ) bear_macd = _detect_bearish( df, highs, macd_hist, "macd_hist", min_macd_hist_diff, min_price_move_pct, max_pair_lookback_bars, future_bars, min_future_move_pct, low, ) buys = _dedupe_signals( _merge_same_bar(bull_rsi + bull_macd), min_bars_between, prefer_lower=True ) sells = _dedupe_signals( _merge_same_bar(bear_rsi + bear_macd), min_bars_between, prefer_lower=False ) return buys, sells @dataclass class _ExtremePoint: """국소 극값.""" bar_index: int price: float rsi: float macd_hist: float datetime: pd.Timestamp def _find_local_extrema( df: pd.DataFrame, series: pd.Series, rsi_vals: pd.Series, macd_hist: pd.Series, order: int, side: str, ) -> list[_ExtremePoint]: """국소 저점·고점을 수집한다.""" points: list[_ExtremePoint] = [] values = series.values for i in range(order, len(df) - order): window = values[i - order : i + order + 1] val = float(values[i]) if side == "low" and val > window.min(): continue if side == "high" and val < window.max(): continue if pd.isna(rsi_vals.iloc[i]) or pd.isna(macd_hist.iloc[i]): continue points.append( _ExtremePoint( bar_index=i, price=val, rsi=float(rsi_vals.iloc[i]), macd_hist=float(macd_hist.iloc[i]), datetime=pd.Timestamp(df.iloc[i]["datetime"]), ) ) return points def _detect_bullish( df: pd.DataFrame, lows: list[_ExtremePoint], indicator: pd.Series, indicator_name: str, min_ind_diff: float, min_price_move_pct: float, max_lookback: int, future_bars: int, min_future_move_pct: float, highs, ) -> list[DivergenceSignal]: """상승 다이버전스 매수를 탐지한다.""" signals: list[DivergenceSignal] = [] if len(lows) < 2: return signals for idx in range(1, len(lows)): curr = lows[idx] for prev in reversed(lows[:idx]): if curr.bar_index - prev.bar_index > max_lookback: break if curr.bar_index - prev.bar_index < 20: continue price_move = (prev.price - curr.price) / prev.price * 100.0 if price_move < min_price_move_pct: continue ind_prev = float(indicator.iloc[prev.bar_index]) ind_curr = float(indicator.iloc[curr.bar_index]) if pd.isna(ind_prev) or pd.isna(ind_curr): continue if not (curr.price < prev.price and ind_curr > ind_prev + min_ind_diff): continue end = min(len(df), curr.bar_index + future_bars + 1) future_high = float(highs[curr.bar_index:end].max()) rally = (future_high - curr.price) / curr.price * 100.0 if rally < min_future_move_pct: continue signals.append( DivergenceSignal( side="buy", bar_index=curr.bar_index, price=round(curr.price, 2), datetime=curr.datetime, indicator=indicator_name, price_prev=round(prev.price, 2), price_curr=round(curr.price, 2), ind_prev=round(ind_prev, 4), ind_curr=round(ind_curr, 4), ) ) break return signals def _detect_bearish( df: pd.DataFrame, highs: list[_ExtremePoint], indicator: pd.Series, indicator_name: str, min_ind_diff: float, min_price_move_pct: float, max_lookback: int, future_bars: int, min_future_move_pct: float, lows, ) -> list[DivergenceSignal]: """하락 다이버전스 매도를 탐지한다.""" signals: list[DivergenceSignal] = [] if len(highs) < 2: return signals for idx in range(1, len(highs)): curr = highs[idx] for prev in reversed(highs[:idx]): if curr.bar_index - prev.bar_index > max_lookback: break if curr.bar_index - prev.bar_index < 20: continue price_move = (curr.price - prev.price) / prev.price * 100.0 if price_move < min_price_move_pct: continue ind_prev = float(indicator.iloc[prev.bar_index]) ind_curr = float(indicator.iloc[curr.bar_index]) if pd.isna(ind_prev) or pd.isna(ind_curr): continue if not (curr.price > prev.price and ind_curr < ind_prev - min_ind_diff): continue end = min(len(df), curr.bar_index + future_bars + 1) future_low = float(lows[curr.bar_index:end].min()) drop = (curr.price - future_low) / curr.price * 100.0 if drop < min_future_move_pct: continue signals.append( DivergenceSignal( side="sell", bar_index=curr.bar_index, price=round(curr.price, 2), datetime=curr.datetime, indicator=indicator_name, price_prev=round(prev.price, 2), price_curr=round(curr.price, 2), ind_prev=round(ind_prev, 4), ind_curr=round(ind_curr, 4), ) ) break return signals def _merge_same_bar(signals: list[DivergenceSignal]) -> list[DivergenceSignal]: """동일 봉·동일 방향 신호를 하나로 합친다.""" by_bar: dict[int, DivergenceSignal] = {} for signal in signals: prev = by_bar.get(signal.bar_index) if prev is None: by_bar[signal.bar_index] = signal continue prev_diff = abs(prev.ind_curr - prev.ind_prev) curr_diff = abs(signal.ind_curr - signal.ind_prev) if curr_diff > prev_diff: by_bar[signal.bar_index] = signal return sorted(by_bar.values(), key=lambda s: s.bar_index) def _dedupe_signals( signals: list[DivergenceSignal], min_bars: int, prefer_lower: bool, ) -> list[DivergenceSignal]: """근접 신호를 병합한다.""" if not signals: return [] sorted_signals = sorted(signals, key=lambda s: s.bar_index) merged: list[DivergenceSignal] = [sorted_signals[0]] for signal in sorted_signals[1:]: last = merged[-1] if signal.bar_index - last.bar_index < min_bars: if prefer_lower and signal.price < last.price: merged[-1] = signal elif not prefer_lower and signal.price > last.price: merged[-1] = signal else: merged.append(signal) return merged